Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
import Foundation

public enum MachineInputProfile: String, Codable, CaseIterable, Hashable,
Identifiable, Sendable
{
case automatic
case macOSGNOME
case macOSHyprland
case linuxPassthrough

public var id: Self { self }

public var displayName: String {
switch self {
case .automatic:
"Automatic"
case .macOSGNOME:
"macOS-style GNOME/Linux"
case .macOSHyprland:
"macOS-style Hyprland"
case .linuxPassthrough:
"Linux passthrough"
}
}

public func resolved(forMachineNamed name: String) -> Self {
guard self == .automatic else {
return self
}
let normalizedName = name.lowercased()
return normalizedName.contains("omarchy")
|| normalizedName.contains("hyprland")
? .macOSHyprland
: .macOSGNOME
}
}

public struct MachineSpec: Codable, Hashable, Sendable {
public var cpuCount: Int
public var memorySizeBytes: UInt64
Expand All @@ -8,6 +43,7 @@ public struct MachineSpec: Codable, Hashable, Sendable {
public var sharedDirectoryPath: String?
public var rosettaEnabled: Bool
public var portForwards: [PortForward]
public var inputProfile: MachineInputProfile

public init(
cpuCount: Int,
Expand All @@ -16,7 +52,8 @@ public struct MachineSpec: Codable, Hashable, Sendable {
architecture: GuestArchitecture,
sharedDirectoryPath: String? = nil,
rosettaEnabled: Bool = false,
portForwards: [PortForward] = []
portForwards: [PortForward] = [],
inputProfile: MachineInputProfile = .automatic
) {
self.cpuCount = cpuCount
self.memorySizeBytes = memorySizeBytes
Expand All @@ -25,6 +62,7 @@ public struct MachineSpec: Codable, Hashable, Sendable {
self.sharedDirectoryPath = sharedDirectoryPath
self.rosettaEnabled = rosettaEnabled
self.portForwards = portForwards
self.inputProfile = inputProfile
}

private enum CodingKeys: String, CodingKey {
Expand All @@ -35,6 +73,7 @@ public struct MachineSpec: Codable, Hashable, Sendable {
case sharedDirectoryPath
case rosettaEnabled
case portForwards
case inputProfile
}

public init(from decoder: any Decoder) throws {
Expand Down Expand Up @@ -64,6 +103,10 @@ public struct MachineSpec: Codable, Hashable, Sendable {
[PortForward].self,
forKey: .portForwards
) ?? []
inputProfile = try container.decodeIfPresent(
MachineInputProfile.self,
forKey: .inputProfile
) ?? .automatic
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,85 @@ func roundTripsLibrarySnapshot() async throws {
#expect(loaded == snapshot)
}

@Test
func decodesExistingLibraryJSONWithAutomaticInputProfile() throws {
let machineID = UUID()
let imageID = UUID()
let machine = Machine(
id: machineID,
name: "Existing Linux",
spec: MachineSpec(
cpuCount: 2,
memorySizeBytes: 4 * 1_024 * 1_024 * 1_024,
diskSizeBytes: 64 * 1_024 * 1_024 * 1_024,
architecture: .arm64
),
sourceImageID: imageID,
disk: MachineDisk(
relativePath: "Machines/\(machineID)/disk.raw",
capacityBytes: 64 * 1_024 * 1_024 * 1_024
),
provisioningState: .ready,
bootMedia: .systemDisk,
backend: .appleVirtualization,
backendState: BackendStateReference(
relativeDirectory: "Machines/\(machineID)/Apple"
)
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let currentData = try encoder.encode(
LibrarySnapshot(machines: [machine])
)
var json = try #require(
JSONSerialization.jsonObject(with: currentData)
as? [String: Any]
)
var machines = try #require(json["machines"] as? [[String: Any]])
var spec = try #require(machines[0]["spec"] as? [String: Any])
spec.removeValue(forKey: "inputProfile")
machines[0]["spec"] = spec
json["machines"] = machines
let existingData = try JSONSerialization.data(withJSONObject: json)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let decoded = try decoder.decode(
LibrarySnapshot.self,
from: existingData
)

#expect(decoded.machines[0].spec.inputProfile == .automatic)
#expect(
decoded.machines[0].spec.inputProfile.resolved(
forMachineNamed: decoded.machines[0].name
) == .macOSGNOME
)
}

@Test
func automaticProfileDetectsOmarchyAndHyprlandNames() {
#expect(
MachineInputProfile.automatic.resolved(
forMachineNamed: "Omarchy 4.0"
) == .macOSHyprland
)
#expect(
MachineInputProfile.automatic.resolved(
forMachineNamed: "Arch Hyprland"
) == .macOSHyprland
)
}

@Test
func explicitHyprlandProfileOverridesGenericMachineName() {
#expect(
MachineInputProfile.macOSHyprland.resolved(
forMachineNamed: "Generic Arch Linux"
) == .macOSHyprland
)
}

@Test
func rejectsUnknownSchema() async throws {
let rootURL = FileManager.default.temporaryDirectory.appending(
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,11 @@ Karabiner-Elements for QEMU guests.

For Omarchy and other Hyprland guests:

1. Open **SimpleVM > Settings**.
2. Select the **macOS-style Hyprland** keyboard profile.
3. Confirm **System input capture** shows `Ready`. If it does not, use
1. Open the machine's **•••** actions menu.
2. Select **macOS-style Hyprland** under **Desktop and Input Profile**. Machines
named for Omarchy or Hyprland use this profile automatically unless
explicitly overridden.
3. In **SimpleVM > Settings**, confirm **System input capture** shows `Ready`. If it does not, use
**Open Accessibility Settings** and enable SimpleVM.
4. Start the guest and choose **Enter Immersion**. System-level shortcuts and
horizontal workspace swipes are now routed to Hyprland.
Expand Down
29 changes: 27 additions & 2 deletions SimpleVMApp/Application/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,8 @@ final class AppModel {
sharedDirectoryPath: String?,
rosettaEnabled: Bool = false,
bootProfileID: String? = nil,
portForwards: [PortForward] = []
portForwards: [PortForward] = [],
inputProfile: MachineInputProfile = .automatic
) async throws -> UUID {
guard let machineStore, let layout else {
throw AppModelError.notInitialized
Expand Down Expand Up @@ -461,7 +462,8 @@ final class AppModel {
architecture: image.architecture,
sharedDirectoryPath: sharedDirectoryPath,
rosettaEnabled: rosettaEnabled,
portForwards: portForwards
portForwards: portForwards,
inputProfile: inputProfile
),
sourceImageID: imageID,
disk: disk,
Expand Down Expand Up @@ -820,6 +822,7 @@ final class AppModel {
present(error: AppModelError.notInitialized)
return
}

guard !exportingMachineIDs.contains(machine.id) else {
present(error: AppModelError.exportInProgress)
return
Expand Down Expand Up @@ -859,6 +862,28 @@ final class AppModel {
}
}

func setInputProfile(
_ profile: MachineInputProfile,
for machine: Machine
) async {
guard let index = machines.firstIndex(where: {
$0.id == machine.id
}) else {
present(error: AppModelError.machineUnavailable)
return
}
let previousProfile = machines[index].spec.inputProfile
machines[index].spec.inputProfile = profile
do {
try await persist()
} catch {
updateMachine(id: machine.id) {
$0.spec.inputProfile = previousProfile
}
present(error: error)
}
}

func removeImage(_ image: MachineImage) async {
guard let imageStore else {
present(error: AppModelError.notInitialized)
Expand Down
2 changes: 1 addition & 1 deletion SimpleVMApp/Application/ImmersionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ final class ImmersionController {
showsExitHint = false
requiresAccessibilityPermission = false
karabinerErrorMessage = nil
KeyboardMappingSettings.shared.deactivateMachinePreset()
KeyboardMappingSettings.shared.deactivate()
window?.toolbar?.isVisible = true

if enteredFullScreen, window?.styleMask.contains(.fullScreen) == true {
Expand Down
42 changes: 41 additions & 1 deletion SimpleVMApp/Features/Machines/MachineDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,33 @@ struct MachineDetailView: View {
}
Divider()
}
Menu("Desktop and Input Profile") {
ForEach(MachineInputProfile.allCases) { profile in
Button {
Task {
await model.setInputProfile(
profile,
for: machine
)
}
} label: {
if machine.spec.inputProfile == profile {
Label(
profile.displayName,
systemImage: "checkmark"
)
} else {
Text(profile.displayName)
}
}
}
if machine.spec.inputProfile == .automatic {
Divider()
Text(
"Active: \(resolvedInputProfile.displayName)"
)
}
}
if machine.hasInstallerAttached {
Button("Eject Installer") {
Task {
Expand Down Expand Up @@ -321,6 +348,7 @@ struct MachineDetailView: View {
countStyle: .file
)
)
StatusValue(title: "Profile", value: inputProfileStatus)
StatusValue(title: "Network", value: "Shared NAT")
Spacer()
}
Expand All @@ -330,7 +358,8 @@ struct MachineDetailView: View {
}

private func enterImmersion() {
KeyboardMappingSettings.shared.activatePreset(
KeyboardMappingSettings.shared.activate(
profile: machine.spec.inputProfile,
forMachineNamed: machine.name
)
switch machine.backend {
Expand Down Expand Up @@ -367,6 +396,17 @@ struct MachineDetailView: View {
}
}

private var resolvedInputProfile: MachineInputProfile {
machine.spec.inputProfile.resolved(forMachineNamed: machine.name)
}

private var inputProfileStatus: String {
if machine.spec.inputProfile == .automatic {
return "Automatic (\(resolvedInputProfile.displayName))"
}
return resolvedInputProfile.displayName
}

private func exportDisk() {
let sanitizedName = machine.name
.replacingOccurrences(of: "/", with: "-")
Expand Down
14 changes: 13 additions & 1 deletion SimpleVMApp/Features/Machines/NewMachineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ struct NewMachineView: View {
@State private var cpuCount = min(4, ProcessInfo.processInfo.processorCount)
@State private var memoryGiB = 4
@State private var diskGiB = 64
@State private var inputProfile = MachineInputProfile.automatic
@State private var source: MachineCreationSource?
@State private var sharedDirectoryPath: String?
@State private var rosettaEnabled = false
Expand Down Expand Up @@ -178,6 +179,16 @@ struct NewMachineView: View {
in: 16...2_048,
step: 8
)
Picker("Desktop and input profile", selection: $inputProfile) {
ForEach(MachineInputProfile.allCases) { profile in
Text(profile.displayName).tag(profile)
}
}
Text(
"Automatic uses Hyprland mappings for known Omarchy or Hyprland machines and GNOME/Linux mappings otherwise."
)
.font(.caption)
.foregroundStyle(.secondary)
}
}

Expand Down Expand Up @@ -327,7 +338,8 @@ struct NewMachineView: View {
guestPort: UInt16(clamping: guestPort)
)
]
: []
: [],
inputProfile: inputProfile
)
onCreated(machineID)
} catch {
Expand Down
Loading
Loading