Skip to content
Draft
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
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ test-contracts:
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-pinch.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-scroll.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-iso-keyboard.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-host-keys.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-media-keys.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-injected-text.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-hvf-memory-reclaim.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-hvf-mapped-sections.py"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,9 @@ port. The `dtc` mirror should be reverted once kernel.org returns.

By default, every launch begins at the launcher. Enable **Skip launcher** to start Omarchy using your saved settings on subsequent launches. A confirmation explains how to return to the launcher; choose **OK** to enable **Skip launcher** or **Cancel** to leave it off. Hold **Option** while opening the app to show the launcher again and change settings or turn **Skip launcher** off. You can also open **Setup → Try Omarchy Settings** inside Omarchy. Skip launcher is saved with the VM workspace; deleting that workspace clears the choice. A missing VM disk always shows the launcher, even if its settings file remains. Reset requests still show the confirmation flow. Startup checks still show any required recovery or error dialogs.

While that menu is open, Try Omarchy behaves like a regular Mac app. **Try Omarchy → Settings…** (**Command-comma**) brings the launcher or its open editor forward without discarding drafts. The menus also provide standard text editing, Services, Hide Others, Show All, and window commands, including Bring All to Front. **Help** opens the user guide, maintenance and recovery notes, or the issue tracker in your browser. After the VM starts, that native app chrome steps aside for Omarchy. **Immersive** is on by default, so Omarchy opens Full Screen with the Mac menu bar and Dock hidden. Turn it off to open a resizable window; if you later enter Full Screen, the Mac menu bar and Dock remain available at the screen edges. Whenever the Omarchy window is focused, Command belongs to the guest as Super in either mode; Accessibility permission lets system shortcuts such as Command-Space reach it before macOS. Microphone and camera access are optional. The first launch takes longer while the app prepares Linux and starts Omarchy's account provisioning.
While that menu is open, Try Omarchy behaves like a regular Mac app. **Try Omarchy → Settings…** (**Command-comma**) brings the launcher or its open editor forward without discarding drafts. The menus also provide standard text editing, Services, Hide Others, Show All, and window commands, including Bring All to Front. **Help** opens the user guide, maintenance and recovery notes, or the issue tracker in your browser. After the VM starts, that native app chrome steps aside for Omarchy. **Immersive** is on by default, so Omarchy opens Full Screen with the Mac menu bar and Dock hidden. Turn it off to open a resizable window; if you later enter Full Screen, the Mac menu bar and Dock remain available at the screen edges. Whenever the Omarchy window is focused, Command belongs to the guest as Super in either mode; Accessibility permission lets system shortcuts such as Command-Space reach it before macOS. The Mac's dedicated brightness, Mission Control, Spotlight, Dictation and Do Not Disturb keys stay with macOS by default; choose **Keyboard → Configure…** to send any of them to Omarchy instead. Media keys (previous, play/pause, next) can also go to Omarchy while its window is focused; volume always stays with macOS. Microphone and camera access are optional. The first launch takes longer while the app prepares Linux and starts Omarchy's account provisioning.

Inside Omarchy, choose **Setup → Try Omarchy Settings**, search for **Try Omarchy Settings**, or run `omarchy-native-settings` to reopen the Mac settings window. You can change **Skip launcher**, permissions, CPU, memory, sharing, port forwarding, and immersive mode here. CPU, memory, sharing, ports, and immersive mode are saved for the next launch; **Restart Omarchy** shuts down Linux and starts a new VM process to apply them. Save your work first. A disposable VM keeps its disk across this restart until you close the app.
Inside Omarchy, choose **Setup → Try Omarchy Settings**, search for **Try Omarchy Settings**, or run `omarchy-native-settings` to reopen the Mac settings window. You can change **Skip launcher**, permissions, CPU, memory, sharing, port forwarding, immersive mode, and keyboard routing here. CPU, memory, sharing, ports, immersive mode, and keyboard routing are saved for the next launch; **Restart Omarchy** shuts down Linux and starts a new VM process to apply them. Save your work first. A disposable VM keeps its disk across this restart until you close the app.

For VM location and reset, choose **Shut Down**. The settings window stays open even with **Skip launcher** enabled; reset still asks for confirmation. **Done** or closing the running settings window returns to Omarchy without stopping it. Existing VMs [receive settings access automatically](guest/README.md#settings-access-from-an-existing-vm) when launched with the updated app, without a reset or manual installation.

Expand Down
36 changes: 36 additions & 0 deletions docs/mac-keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@ hl.config({

Save, then run `hyprctl reload` and `hyprctl configerrors`.

## Dedicated keys

In its default mode an Apple keyboard sends the dedicated keys as plain
key events with their own keycodes, not as F-keys, and the exact codes
differ by keyboard:

| Key | Built-in M1 Max | Magic Keyboard A1843 |
|---|---|---|
| Brightness down/up (F1/F2) | system-defined events, never reach QEMU's key path | 145 / 144 |
| Mission Control (F3) | 160 | 160 |
| Spotlight / Launchpad (F4) | 177 | 131 |
| Dictation / Siri (F5) | 176 | plain F5 |
| Do Not Disturb (F6) | 178 | plain F6 |

Built-in Mac keyboards send brightness as `NX_SYSDEFINED` system-defined
events that always stay with macOS; there is no keycode to route. Full
grab would otherwise swallow the rest, so the launcher passes the ones the
Keyboard setting leaves with macOS to QEMU as
`-display cocoa,host-keys=131:144:145:160:176:177:178`, and the Cocoa tap
hands those straight back to macOS. The setting is stored under
`keyboardRoutingPreferences` and published as `OMARCHY_QEMU_GPU_HOST_KEYS`
(comma-separated; unset means the defaults, empty means none). With "Use
F1, F2, etc. as standard function keys" on, the same caps arrive as F-keys
and always reach Omarchy.

Media transport keys arrive as `NX_SYSDEFINED` aux-control events, which
full grab's tap does not capture by default. When the Keyboard setting
sends them to Omarchy, the launcher publishes `OMARCHY_QEMU_GPU_MEDIA_KEYS=1`
and passes `media-keys=on` to QEMU, whose tap then also takes previous,
play/pause and next while the guest has the keyboard and sends them as
`audioprev`, `audioplay` and `audionext`; Omarchy's Hyprland bindings pass
them to `omarchy-shell media`. QEMU's held-key release lifts a media key
held when focus leaves. Volume, mute, brightness and backlight events
always pass through. Like the other rows, the setting applies on the next
launch.

## Validation

`make test` checksums the Cocoa patch, compiles the ISO swap helper, and
Expand Down
261 changes: 261 additions & 0 deletions macos/Sources/OmarchyVMHelper/KeyboardRoutingEditor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import AppKit

/// Edits a draft; only Save publishes it.
@MainActor
final class KeyboardRoutingEditor: NSObject, NSWindowDelegate {
private let saveHandler: (KeyboardRoutingPreferences) -> Void
private let closeHandler: () -> Void
private(set) var window: NSWindow!
private let brightnessPopup = NSPopUpButton()
private let missionControlPopup = NSPopUpButton()
private let spotlightPopup = NSPopUpButton()
private let dictationPopup = NSPopUpButton()
private let doNotDisturbPopup = NSPopUpButton()
private let mediaPopup = NSPopUpButton()
private var didClose = false

init(
preferences: KeyboardRoutingPreferences,
save: @escaping (KeyboardRoutingPreferences) -> Void,
didClose: @escaping () -> Void
) {
saveHandler = save
closeHandler = didClose
super.init()
buildWindow()
setFields(preferences)
}

func beginSheet(for parent: NSWindow) {
parent.beginSheet(window)
window.makeFirstResponder(brightnessPopup)
}

func dismiss() {
guard !didClose else { return }
didClose = true
window.sheetParent?.endSheet(window)
window.orderOut(nil)
closeHandler()
}

func windowShouldClose(_ sender: NSWindow) -> Bool {
dismiss()
return false
}

private func buildWindow() {
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 580, height: 640),
styleMask: [.titled, .closable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "Keyboard"
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.isReleasedWhenClosed = false
window.appearance = NSAppearance(named: .darkAqua)
window.backgroundColor = OmarchyStartMenuTheme.background
window.delegate = self
window.setAccessibilityLabel("Keyboard")

let title = label("Keyboard", size: 22, weight: .bold)
let explanation = label(
"Choose which side gets the Mac's dedicated keys while Omarchy is focused. Changes apply on the next launch.",
size: 11, muted: true
)
let heading = NSStackView(views: [title, explanation])
heading.orientation = .vertical
heading.alignment = .leading
heading.spacing = 8
explanation.widthAnchor.constraint(equalTo: heading.widthAnchor).isActive = true

configure(brightnessPopup, identifier: "brightness", accessibilityLabel: "Brightness keys")
configure(missionControlPopup, identifier: "mission-control", accessibilityLabel: "Mission Control key")
configure(spotlightPopup, identifier: "spotlight", accessibilityLabel: "Spotlight or Launchpad key")
configure(dictationPopup, identifier: "dictation", accessibilityLabel: "Dictation or Siri key")
configure(doNotDisturbPopup, identifier: "do-not-disturb", accessibilityLabel: "Do Not Disturb key")
configure(mediaPopup, identifier: "media", accessibilityLabel: "Media keys")

let rowViews = [
routeRow(
title: "Brightness",
detail: "Display brightness down and up (F1, F2). Built-in Mac keyboards always keep these with macOS.",
control: brightnessPopup
),
routeRow(title: "Mission Control", detail: "The F3 key", control: missionControlPopup),
routeRow(title: "Spotlight / Launchpad", detail: "The F4 key", control: spotlightPopup),
routeRow(
title: "Dictation / Siri",
detail: "The F5 key on built-in Mac keyboards",
control: dictationPopup
),
routeRow(
title: "Do Not Disturb",
detail: "The F6 key on built-in Mac keyboards",
control: doNotDisturbPopup
),
routeRow(title: "Media", detail: "Previous, play/pause, next (F7–F9). Only while Omarchy is focused.", control: mediaPopup),
]
var stacked: [NSView] = []
for (index, row) in rowViews.enumerated() {
if index > 0 { stacked.append(separator()) }
stacked.append(row)
}
let rows = NSStackView(views: stacked)
rows.orientation = .vertical
rows.alignment = .leading
rows.spacing = 0
rows.translatesAutoresizingMaskIntoConstraints = false
let card = NSView()
card.wantsLayer = true
card.layer?.backgroundColor = OmarchyStartMenuTheme.darkBackground.cgColor
card.layer?.cornerRadius = 8
card.layer?.borderWidth = 1
card.layer?.borderColor = OmarchyStartMenuTheme.border.cgColor
card.addSubview(rows)
NSLayoutConstraint.activate([
rows.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
rows.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
rows.topAnchor.constraint(equalTo: card.topAnchor),
rows.bottomAnchor.constraint(equalTo: card.bottomAnchor),
])
for view in stacked {
view.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true
}

let note = label(
"Volume and mute always stay with macOS. With \"Use F1, F2, etc. as standard function keys\" on, these keys reach Omarchy as F-keys.",
size: 11, muted: true
)

let defaults = button("Use Defaults", style: .secondary, action: #selector(useDefaults), identifier: "defaults")
let cancel = button("Cancel", style: .secondary, action: #selector(cancel), identifier: "cancel")
cancel.keyEquivalent = "\u{1b}"
let save = button("Save", style: .primary, action: #selector(save), identifier: "save")
save.keyEquivalent = "\r"
let spacer = NSView()
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
let actions = NSStackView(views: [defaults, spacer, cancel, save])
actions.spacing = 8

let stack = NSStackView(views: [heading, card, note, actions])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 14
stack.translatesAutoresizingMaskIntoConstraints = false
let content = NSView()
content.wantsLayer = true
content.layer?.backgroundColor = OmarchyStartMenuTheme.background.cgColor
content.addSubview(stack)
window.contentView = content
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 26),
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -26),
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 38),
stack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -22),
heading.widthAnchor.constraint(equalTo: stack.widthAnchor),
card.widthAnchor.constraint(equalTo: stack.widthAnchor),
note.widthAnchor.constraint(equalTo: stack.widthAnchor),
actions.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
}

private func configure(_ popup: NSPopUpButton, identifier: String, accessibilityLabel: String) {
popup.addItem(withTitle: "macOS")
popup.lastItem?.tag = 0
popup.addItem(withTitle: "Omarchy")
popup.lastItem?.tag = 1
popup.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
popup.identifier = NSUserInterfaceItemIdentifier("keyboard-routing-\(identifier)")
popup.setAccessibilityLabel(accessibilityLabel)
}

private func label(
_ text: String, size: CGFloat, weight: NSFont.Weight = .regular, muted: Bool = false
) -> NSTextField {
let field = NSTextField(wrappingLabelWithString: text)
field.font = .monospacedSystemFont(ofSize: size, weight: weight)
field.textColor = muted ? OmarchyStartMenuTheme.muted : OmarchyStartMenuTheme.foreground
return field
}

private func separator() -> NSView {
let view = NSView()
view.wantsLayer = true
view.layer?.backgroundColor = OmarchyStartMenuTheme.separator.cgColor
view.heightAnchor.constraint(equalToConstant: 1).isActive = true
return view
}

private func routeRow(title: String, detail: String, control: NSView) -> NSView {
let labels = NSStackView(views: [
label(title, size: 13, weight: .bold),
label(detail, size: 10, muted: true),
])
labels.orientation = .vertical
labels.alignment = .leading
labels.spacing = 5
let row = NSView()
labels.translatesAutoresizingMaskIntoConstraints = false
control.translatesAutoresizingMaskIntoConstraints = false
row.addSubview(labels)
row.addSubview(control)
NSLayoutConstraint.activate([
row.heightAnchor.constraint(equalToConstant: 64),
labels.leadingAnchor.constraint(equalTo: row.leadingAnchor),
labels.centerYAnchor.constraint(equalTo: row.centerYAnchor),
labels.trailingAnchor.constraint(lessThanOrEqualTo: control.leadingAnchor, constant: -16),
control.trailingAnchor.constraint(equalTo: row.trailingAnchor),
control.centerYAnchor.constraint(equalTo: row.centerYAnchor),
control.widthAnchor.constraint(equalToConstant: 130),
])
return row
}

private func button(
_ title: String, style: OmarchyControlStyle, action: Selector, identifier: String
) -> OmarchyActionButton {
let button = OmarchyActionButton(title: title, style: style, target: self, action: action)
button.identifier = NSUserInterfaceItemIdentifier("keyboard-routing-\(identifier)")
button.heightAnchor.constraint(equalToConstant: 32).isActive = true
button.widthAnchor.constraint(equalToConstant: identifier == "defaults" ? 130 : 82).isActive = true
return button
}

@objc private func cancel() { dismiss() }

@objc private func save() {
saveHandler(draft())
dismiss()
}

@objc private func useDefaults() { setFields(.defaults) }

private func setFields(_ preferences: KeyboardRoutingPreferences) {
brightnessPopup.selectItem(withTag: Self.tag(preferences.brightness))
missionControlPopup.selectItem(withTag: Self.tag(preferences.missionControl))
spotlightPopup.selectItem(withTag: Self.tag(preferences.spotlight))
dictationPopup.selectItem(withTag: Self.tag(preferences.dictation))
doNotDisturbPopup.selectItem(withTag: Self.tag(preferences.doNotDisturb))
mediaPopup.selectItem(withTag: Self.tag(preferences.media))
}

private func draft() -> KeyboardRoutingPreferences {
KeyboardRoutingPreferences(
brightness: Self.route(brightnessPopup),
missionControl: Self.route(missionControlPopup),
spotlight: Self.route(spotlightPopup),
dictation: Self.route(dictationPopup),
doNotDisturb: Self.route(doNotDisturbPopup),
media: Self.route(mediaPopup)
)
}

private static func tag(_ route: KeyRoute) -> Int { route == .omarchy ? 1 : 0 }

private static func route(_ popup: NSPopUpButton) -> KeyRoute {
popup.selectedItem?.tag == 1 ? .omarchy : .macOS
}
}
Loading
Loading