Skip to content

Commit 25398f8

Browse files
Fix Mac ISO keyboard geometry from the bundled helper
Detect ANSI/ISO/JIS once in omarchy-vm-helper, fail closed on unknown classes, and feed the same value to Cocoa and tryomarchy.keyboard= so ISO Section/Grave keycodes match guest applealu_* models. Related to #67. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 12d7c8a commit 25398f8

20 files changed

Lines changed: 685 additions & 32 deletions

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ doctor:
5454

5555
test:
5656
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-pinch.py"
57+
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-iso-keyboard.py"
5758
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-virtio-pinch.py"
5859
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/tests/test-build-cache.py"
5960
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/tests/test-pack-app-icon.py"

docs/architecture.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ pinch and releases them on cancellation or focus loss; the guest disables
4949
tapping for this gesture-only device. See [pinch zoom](pinch-zoom.md) for the
5050
input contract, existing-guest setup, and integration validation.
5151

52+
Mac keyboard geometry (ANSI / ISO / JIS) is detected by the compiled helper
53+
once per launch. Unknown Carbon classes fail the launch instead of guessing.
54+
That value is exported to Cocoa (`TRYOMARCHY_KEYBOARD`) and appended as
55+
`tryomarchy.keyboard=` so ISO Section/Grave keycodes and the guest
56+
`applealu_*` model stay aligned on new factory users. Existing homes should
57+
set `kb_model` to the matching `applealu_*` value. Do not `dofile`
58+
`/usr/share/try-omarchy/apple-keyboard-input.lua` unless a rebuilt guest
59+
image installed that file. Cocoa swaps only when the env is `iso`. See
60+
[Mac keyboard](mac-keyboard.md).
61+
5262
The macOS helper opens an authenticated connection to QEMU's private,
5363
single-client machine protocol socket before host sleep and retains that control
5464
session through wake. Before macOS sleeps it synchronously pauses the guest

docs/mac-keyboard.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Mac keyboard geometry
2+
3+
Try Omarchy maps the host Mac keyboard class (ANSI / ISO / JIS) into the
4+
guest. That fixes the ISO Section / extra-ISO key inversion for every layout
5+
and, for xkeyboard-config's Mac vendor layouts (`ch de dk fi fr gb is it
6+
latam nl no pt se us` with an empty variant), selects Macintosh legends.
7+
8+
## What the host does
9+
10+
1. The compiled helper `omarchy-vm-helper --host-keyboard-geometry` reports
11+
`ansi`, `iso`, or `jis` via Carbon `KBGetLayoutType`. DMG users do not need
12+
Xcode or `/usr/bin/swift`.
13+
2. The launcher exports `TRYOMARCHY_KEYBOARD` and appends
14+
`tryomarchy.keyboard=ansi|iso|jis` on a normal VM launch (launch-time only,
15+
like `tryomarchy.ssh_access`). Recovery unsets that env and does not add
16+
the token. Inspect-only launches do not probe.
17+
3. QEMU Cocoa swaps Linux `KEY_GRAVE` and `KEY_102ND` only when
18+
`TRYOMARCHY_KEYBOARD=iso`. Missing env does not swap and does not
19+
re-query HID. Cached for the process so it cannot drift from the guest
20+
model. See [issue #67](https://github.com/omacom/try-omarchy/issues/67).
21+
22+
ANSI and JIS never take the keycode swap. The helper reports `ansi` only
23+
for Carbon `kKeyboardANSI`. Unknown classes, a missing helper, or invalid
24+
output fail the launch instead of guessing. `--reset-storage-only` skips
25+
the probe.
26+
27+
An external keyboard that reports a different class than the built-in board
28+
is classified at launch. Replug after boot does not change the guest model
29+
or the Cocoa swap for that run.
30+
31+
## What the guest does
32+
33+
`/usr/share/try-omarchy/apple-keyboard-input.lua` (seeded into new users'
34+
`~/.config/hypr/input.lua`) reads the cmdline token and sets only:
35+
36+
```lua
37+
kb_model = "applealu_ansi" -- or applealu_iso / applealu_jis
38+
```
39+
40+
Omarchy's `XKBLAYOUT` / `XKBVARIANT` from setup are left unchanged. Upstream
41+
xkeyboard-config maps `applealu_*` onto Macintosh symbol maps for the Mac
42+
vendor layouts listed above when the variant is empty. Other layouts keep
43+
their PC symbols; they still get the ISO keycode correction.
44+
45+
If the token is missing (older app builds), the guest does nothing.
46+
47+
## Existing VMs after upgrading the app
48+
49+
1. Install a Try Omarchy build that includes the Cocoa ISO patch and cmdline
50+
token. The keycode swap applies immediately.
51+
2. **Remove any local `frmac` (or similar) TLDE/LSGT symbol swap.** Keeping it
52+
after the host fix double-inverts those keys again.
53+
3. For Macintosh legends on an existing user config, set `kb_model` in
54+
`~/.config/hypr/input.lua` to `applealu_ansi`, `applealu_iso`, or
55+
`applealu_jis`. App upgrade does not install
56+
`/usr/share/try-omarchy/apple-keyboard-input.lua` onto an old disk;
57+
do not `dofile` that path unless a rebuilt guest image put the file
58+
there.
59+
4. Reload Hyprland (`hyprctl reload`).
60+
61+
New factory users get the `dofile` from skel automatically. Omarchy
62+
*Setup → Input* rewrites `~/.config/hypr/input.lua`; re-add the `dofile`
63+
after that if you still want Macintosh legends.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- Set Hyprland's XKB model from the host Mac keyboard geometry.
2+
-- Try Omarchy's launcher appends tryomarchy.keyboard=ansi|iso|jis to the
3+
-- kernel command line. applealu_* models pull Macintosh symbol maps through
4+
-- upstream xkb rules for the Omarchy-selected layout without forcing variants.
5+
-- Missing or unknown tokens are a no-op so older app builds stay safe (never
6+
-- guess iso — that misplaces grave/tilde on ANSI boards).
7+
8+
local function host_keyboard_geometry()
9+
local file = io.open("/proc/cmdline", "r")
10+
if not file then
11+
return nil
12+
end
13+
local cmdline = file:read("*a") or ""
14+
file:close()
15+
16+
for token in cmdline:gmatch("%S+") do
17+
local value = token:match("^tryomarchy%.keyboard=(%w+)$")
18+
if value == "ansi" or value == "iso" or value == "jis" then
19+
return value
20+
end
21+
end
22+
return nil
23+
end
24+
25+
local geometry = host_keyboard_geometry()
26+
if not geometry then
27+
return
28+
end
29+
30+
hl.config({
31+
input = {
32+
kb_model = "applealu_" .. geometry,
33+
},
34+
})

guest/scripts/materialize-omarchy.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ cat >> "$root/etc/skel/.config/hypr/input.lua" <<'EOF'
150150
151151
-- Try Omarchy's host pinch device carries gestures only.
152152
dofile("/usr/share/try-omarchy/pinch-input.lua")
153+
154+
-- Match Apple keyboard geometry (ansi/iso/jis) from the host cmdline.
155+
dofile("/usr/share/try-omarchy/apple-keyboard-input.lua")
153156
EOF
154157
install_file 0644 "$source_dir/default/bashrc" "$root/etc/skel/.bashrc"
155158
mkdir -p "$root/etc/skel/.local/share/applications"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- Run apple-keyboard-input.lua against a fake /proc/cmdline (arg[1]).
2+
local cmdline = assert(arg[1], "cmdline required")
3+
local overlay = assert(arg[2], "overlay path required")
4+
local recorded = nil
5+
6+
hl = {
7+
config = function(tbl)
8+
recorded = tbl
9+
end,
10+
}
11+
12+
function io.open(path)
13+
if path == "/proc/cmdline" then
14+
return {
15+
read = function()
16+
return cmdline
17+
end,
18+
close = function() end,
19+
}
20+
end
21+
return nil
22+
end
23+
24+
assert(loadfile(overlay))()
25+
26+
if recorded and recorded.input and recorded.input.kb_model then
27+
io.write(recorded.input.kb_model)
28+
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env python3
2+
"""Contracts for guest Apple keyboard XKB model selection."""
3+
4+
from __future__ import annotations
5+
6+
from pathlib import Path
7+
import shutil
8+
import subprocess
9+
import unittest
10+
11+
12+
GUEST = Path(__file__).resolve().parents[1]
13+
LUA = GUEST / "native-overlay/usr/share/try-omarchy/apple-keyboard-input.lua"
14+
HARNESS = GUEST / "tests/apple_keyboard_input_harness.lua"
15+
MATERIALIZE = GUEST / "scripts/materialize-omarchy.sh"
16+
17+
18+
class AppleKeyboardInputTests(unittest.TestCase):
19+
def test_overlay_and_materialize_wire_model_only(self) -> None:
20+
lua = LUA.read_text(encoding="utf-8")
21+
materialize = MATERIALIZE.read_text(encoding="utf-8")
22+
23+
self.assertIn('kb_model = "applealu_" .. geometry', lua)
24+
self.assertNotIn("kb_layout", lua)
25+
self.assertNotIn("kb_variant", lua)
26+
self.assertIn("tryomarchy%.keyboard=", lua)
27+
self.assertIn(
28+
'dofile("/usr/share/try-omarchy/apple-keyboard-input.lua")',
29+
materialize,
30+
)
31+
32+
def test_lua_geometry_token_parsing(self) -> None:
33+
lua = shutil.which("lua") or shutil.which("lua5.4") or shutil.which("luajit")
34+
if lua is None:
35+
self.skipTest("lua interpreter is not available")
36+
37+
cases = {
38+
"root=/dev/vda rw": None,
39+
"root=/dev/vda tryomarchy.keyboard=iso": "applealu_iso",
40+
"tryomarchy.keyboard=ansi console=hvc0": "applealu_ansi",
41+
"xtryomarchy.keyboard=iso": None,
42+
"tryomarchy.keyboard=isoextra": None,
43+
"tryomarchy.keyboard=jis": "applealu_jis",
44+
"tryomarchy.keyboard=fr": None,
45+
}
46+
for cmdline, expected in cases.items():
47+
completed = subprocess.run(
48+
[lua, str(HARNESS), cmdline, str(LUA)],
49+
check=True,
50+
capture_output=True,
51+
text=True,
52+
)
53+
model = completed.stdout or None
54+
self.assertEqual(model, expected, cmdline)
55+
56+
57+
if __name__ == "__main__":
58+
unittest.main()

guest/tests/verify.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,16 @@ def main() -> None:
723723
and 'toggles/flags.lua' in materialize,
724724
"skel hypr toggles seed only flags.lua, not the catalog",
725725
)
726+
apple_keyboard = read(
727+
GUEST / "native-overlay/usr/share/try-omarchy/apple-keyboard-input.lua"
728+
)
729+
check(
730+
'kb_model = "applealu_" .. geometry' in apple_keyboard
731+
and "kb_layout" not in apple_keyboard
732+
and "kb_variant" not in apple_keyboard
733+
and 'dofile("/usr/share/try-omarchy/apple-keyboard-input.lua")' in materialize,
734+
"skel input loads Apple keyboard geometry without overriding layout",
735+
)
726736

727737
configure = read(GUEST / "scripts/configure-rootfs.sh")
728738
check(
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Carbon
2+
3+
/// Physical Mac keyboard class used for guest XKB `applealu_*` models and
4+
/// the ISO Section/Grave Cocoa keycode swap.
5+
enum HostKeyboardGeometry: String {
6+
case ansi
7+
case iso
8+
case jis
9+
10+
struct UnknownLayoutType: Error, Equatable {
11+
let rawValue: Int
12+
}
13+
14+
/// Map a Carbon `KBGetLayoutType` value. Unknown classes fail instead of
15+
/// guessing ANSI, so Cocoa and the guest cannot drift apart.
16+
static func classify(_ layoutType: Int) throws -> HostKeyboardGeometry {
17+
switch layoutType {
18+
case Int(kKeyboardANSI):
19+
return .ansi
20+
case Int(kKeyboardISO):
21+
return .iso
22+
case Int(kKeyboardJIS):
23+
return .jis
24+
default:
25+
throw UnknownLayoutType(rawValue: layoutType)
26+
}
27+
}
28+
29+
/// Report the current host keyboard geometry. Compiled into the app so
30+
/// DMG users do not need Xcode or `/usr/bin/swift`.
31+
static func detect() throws -> HostKeyboardGeometry {
32+
try classify(Int(KBGetLayoutType(Int16(LMGetKbdType()))))
33+
}
34+
}

macos/Sources/OmarchyVMHelper/main.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Foundation
55
private var terminationSignalSources: [DispatchSourceSignal] = []
66

77
private func usage() -> Never {
8-
fputs("Usage: omarchy-vm-helper --run-qemu [--ephemeral | --reset-storage | --reset-storage-only] [GUEST_DIR] | --bridge-command-super QEMU_PID QMP_SOCKET | --bridge-native-audio QEMU_PID SOCKET ROUTE_DIRECTORY | --bridge-native-authentication QEMU_PID SOCKET | --bridge-native-camera QEMU_PID SOCKET | --bridge-native-clipboard QEMU_PID SOCKET\n", stderr)
8+
fputs("Usage: omarchy-vm-helper --run-qemu [--ephemeral | --reset-storage | --reset-storage-only] [GUEST_DIR] | --host-keyboard-geometry | --bridge-command-super QEMU_PID QMP_SOCKET | --bridge-native-audio QEMU_PID SOCKET ROUTE_DIRECTORY | --bridge-native-authentication QEMU_PID SOCKET | --bridge-native-camera QEMU_PID SOCKET | --bridge-native-clipboard QEMU_PID SOCKET\n", stderr)
99
exit(64)
1010
}
1111

@@ -139,6 +139,17 @@ do {
139139
exit(0)
140140
}
141141

142+
if arguments.first == "--host-keyboard-geometry" {
143+
guard arguments.count == 1 else { usage() }
144+
do {
145+
fputs(try HostKeyboardGeometry.detect().rawValue + "\n", stdout)
146+
exit(0)
147+
} catch {
148+
fputs("unknown host Mac keyboard geometry\n", stderr)
149+
exit(1)
150+
}
151+
}
152+
142153
if arguments.first == "--run-qemu" {
143154
guard let request = QEMUGPULaunchRequest(arguments: Array(arguments.dropFirst())) else {
144155
usage()

0 commit comments

Comments
 (0)