From 1f7a6fe9b316e9e08f1aecd6de63f793d9d546bb Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 15 Jul 2026 10:32:06 +0900 Subject: [PATCH] =?UTF-8?q?fix(nix):=20enforce=20Kanary=20Caps=20Lock?= =?UTF-8?q?=E2=86=92Control=20remap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kanary intercepts the physical keyboard and re-emits events through a virtual HID device when its window/input features are enabled, which bypasses the global hidutil mapping from system.keyboard.remapCapsLockToControl. Caps Lock therefore reverts to Caps Lock unless Kanary's own capsLockRemappedToControl is true. - Add script/macos/kanary-enforce-caps-control.sh: idempotent helper that flips only that one field to true and restarts Kanary on drift, and is a silent no-op once correct. - Add nix/home/kanary.nix: install the helper and re-assert on every home-manager activation. Wire it into nix/home/default.nix. - Correct ADR 0016 to reflect that the hidutil baseline is bypassed by Kanary and that Caps→Control is owned by Kanary's setting. - Add a test covering the enforcement wiring. --- .../0016-use-kanary-for-keyboard-remapping.md | 30 ++++-- nix/home/default.nix | 1 + nix/home/kanary.nix | 25 +++++ script/macos/kanary-enforce-caps-control.sh | 94 +++++++++++++++++++ test/nix-darwin-config.test.js | 14 +++ 5 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 nix/home/kanary.nix create mode 100755 script/macos/kanary-enforce-caps-control.sh diff --git a/docs/adr/0016-use-kanary-for-keyboard-remapping.md b/docs/adr/0016-use-kanary-for-keyboard-remapping.md index a97f1d3a..52867d64 100644 --- a/docs/adr/0016-use-kanary-for-keyboard-remapping.md +++ b/docs/adr/0016-use-kanary-for-keyboard-remapping.md @@ -34,7 +34,15 @@ Stop using Karabiner Elements for local keyboard remapping. - Keep Google Japanese Input installed. - Require Kanary for local keyboard remapping through a nix-darwin system check. - Use nix-darwin's built-in `system.keyboard.remapCapsLockToControl` option for - the baseline Caps Lock to Control mapping. + the baseline Caps Lock to Control mapping (hidutil). Note: this baseline is + only effective while Kanary is not intercepting the keyboard; see below. +- Enable Kanary's own `capsLockRemappedToControl` setting and re-assert it from + home-manager on every activation + (`nix/home/kanary.nix` + + `script/macos/kanary-enforce-caps-control.sh`). Kanary's window-move/resize and + input-switching features grab the physical keyboard and re-emit events through + a virtual HID device, which bypasses the hidutil mapping, so Caps Lock to + Control must be owned by Kanary when those features are enabled. - Install Kanary manually from `https://kanary.download/download` until a stable package-manager source exists. - Use Kanary to manage: @@ -49,14 +57,24 @@ Stop using Karabiner Elements for local keyboard remapping. ## Consequences `darwin-rebuild switch --flake ~/develop/github.com/keito4/config/nix` no longer -installs or configures Karabiner. Caps Lock to Control is handled by nix-darwin -so the baseline remap works even before Kanary app-level settings are configured. +installs or configures Karabiner. The nix-darwin `remapCapsLockToControl` hidutil +mapping is kept as a baseline, but it is bypassed once Kanary is running with its +keyboard features enabled, because Kanary re-emits key events through a virtual +HID device below which the hidutil mapping no longer applies. Caps Lock to +Control is therefore owned by Kanary's `capsLockRemappedToControl` setting, which +`nix/home/kanary.nix` re-asserts on every activation via +`script/macos/kanary-enforce-caps-control.sh`. The helper is idempotent: it is a +silent no-op when the setting is already true (or when Kanary has not been +launched yet) and only flips the single field and restarts Kanary when it detects +drift (for example after the setting is toggled off in Kanary's UI). + Kanary must be present before activation, and the nix-darwin configuration fails early with an actionable message when `Kanary.app` is missing. -Kanary installation and app-level settings are manual for now. The repository -avoids adding a bespoke ZIP download, checksum, and install flow while Kanary is -not available through the normal package sources used by this configuration. +Kanary installation is manual for now, and the `capsLockRemappedToControl` field +is the only Kanary app-level setting managed declaratively. The repository avoids +adding a bespoke ZIP download, checksum, and install flow while Kanary is not +available through the normal package sources used by this configuration. The `Ctrl+Shift+J` and `Ctrl+Shift+;` IME mappings are handled by skhd as a user-level hotkey daemon, so they work in cmux terminals even when the foreground diff --git a/nix/home/default.nix b/nix/home/default.nix index 226cd0d2..8f73ebca 100644 --- a/nix/home/default.nix +++ b/nix/home/default.nix @@ -8,6 +8,7 @@ ./dotfiles.nix ./agent-commands.nix ./input-source.nix + ./kanary.nix ./cmux.nix ]; diff --git a/nix/home/kanary.nix b/nix/home/kanary.nix new file mode 100644 index 00000000..bf3a62c4 --- /dev/null +++ b/nix/home/kanary.nix @@ -0,0 +1,25 @@ +{ + config, + lib, + configRoot, + ... +}: + +{ + # Helper that enforces Kanary's built-in Caps Lock -> Control remap. + home.file.".local/bin/kanary-enforce-caps-control" = { + source = configRoot + /script/macos/kanary-enforce-caps-control.sh; + executable = true; + force = true; + }; + + # When Kanary's keyboard features are enabled it intercepts the physical + # keyboard and re-emits events through a virtual HID device, which bypasses the + # global hidutil mapping set by `system.keyboard.remapCapsLockToControl`. As a + # result Caps Lock -> Control only takes effect when Kanary's own + # `capsLockRemappedToControl` is true. Re-assert it on every activation. + # The helper is idempotent and a silent no-op once the setting is correct. + home.activation.enforceKanaryCapsControl = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + $DRY_RUN_CMD "${config.home.homeDirectory}/.local/bin/kanary-enforce-caps-control" || true + ''; +} diff --git a/script/macos/kanary-enforce-caps-control.sh b/script/macos/kanary-enforce-caps-control.sh new file mode 100755 index 00000000..8d216c77 --- /dev/null +++ b/script/macos/kanary-enforce-caps-control.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# +# Ensure Kanary's built-in "Caps Lock -> Control" remap is enabled. +# +# Why this exists: +# nix-darwin's `system.keyboard.remapCapsLockToControl` sets a global hidutil +# UserKeyMapping. In practice, when Kanary's window-move/resize and +# input-switching features are enabled, Kanary grabs the physical keyboard and +# re-emits events through a virtual HID keyboard. Those synthesized events +# BYPASS the hidutil mapping, so Caps Lock reverts to Caps Lock unless Kanary's +# own `capsLockRemappedToControl` is true. See ADR 0016. +# +# Behaviour: +# - No-op (silent, exit 0) when the setting is already true, or when Kanary has +# never been launched (settings file absent), or when the schema is unknown. +# - When the setting is false, back up the settings plist, flip only that one +# field to true, and (if Kanary was running) restart Kanary so it reloads. +# +# Idempotent and safe to run on every `darwin-rebuild switch`. + +set -euo pipefail + +DOMAIN="download.kanary.settings" +PLIST="${HOME}/Library/Preferences/${DOMAIN}.plist" + +log() { printf 'kanary: %s\n' "$*"; } + +# Kanary never launched yet -> nothing to enforce. +if [ ! -f "$PLIST" ]; then + log "settings not found (launch Kanary once to create them); skipping" + exit 0 +fi + +read_caps() { + plutil -extract app_settings raw -o - "$PLIST" 2>/dev/null \ + | base64 -D 2>/dev/null \ + | python3 -c 'import sys, json +try: + d = json.load(sys.stdin) + v = d["settings"]["windowMoveResize"]["capsLockRemappedToControl"] + print("true" if v is True else "false" if v is False else "unknown") +except Exception: + print("unknown")' +} + +current="$(read_caps || echo unknown)" + +case "$current" in + true) + exit 0 + ;; + unknown) + log "could not read capsLockRemappedToControl (schema changed?); skipping" + exit 0 + ;; +esac + +# current == false -> enable it. +log "capsLockRemappedToControl is false; enabling Caps Lock -> Control" + +ts="$(date +%Y%m%d%H%M%S 2>/dev/null || echo backup)" +cp "$PLIST" "${PLIST}.bak-${ts}" 2>/dev/null || true + +hex="$(python3 - "$PLIST" <<'PY' +import sys, plistlib, json +with open(sys.argv[1], "rb") as f: + d = plistlib.load(f) +obj = json.loads(d["app_settings"]) +obj["settings"]["windowMoveResize"]["capsLockRemappedToControl"] = True +sys.stdout.write(json.dumps(obj, separators=(",", ":")).encode().hex()) +PY +)" || { log "failed to build updated settings; aborting"; exit 0; } + +was_running=false +if pgrep -x Kanary >/dev/null 2>&1; then + was_running=true + osascript -e 'quit app "Kanary"' 2>/dev/null || pkill -x Kanary 2>/dev/null || true + for _ in 1 2 3 4 5; do + pgrep -x Kanary >/dev/null 2>&1 || break + sleep 1 + done + if pgrep -x Kanary >/dev/null 2>&1; then + pkill -9 -x Kanary 2>/dev/null || true + fi +fi + +# Write through cfprefsd while Kanary is not running so it is not clobbered. +defaults write "$DOMAIN" app_settings -data "$hex" + +if [ "$was_running" = true ]; then + open -a Kanary 2>/dev/null || true +fi + +log "Caps Lock -> Control enabled" diff --git a/test/nix-darwin-config.test.js b/test/nix-darwin-config.test.js index f6409e13..20a49f72 100644 --- a/test/nix-darwin-config.test.js +++ b/test/nix-darwin-config.test.js @@ -247,6 +247,20 @@ describe('nix-darwin and home-manager macOS configuration', () => { expect(kanaryModule).toContain('https://kanary.download/download'); }); + test('Kanary Caps Lock to Control is enforced from home-manager', () => { + const homeDefault = readRepoFile('nix/home/default.nix'); + const kanaryHome = readRepoFile('nix/home/kanary.nix'); + const enforceScript = readRepoFile('script/macos/kanary-enforce-caps-control.sh'); + const adr = readRepoFile('docs/adr/0016-use-kanary-for-keyboard-remapping.md'); + + expect(homeDefault).toContain('./kanary.nix'); + expect(kanaryHome).toContain('.local/bin/kanary-enforce-caps-control'); + expect(kanaryHome).toContain('home.activation.enforceKanaryCapsControl'); + expect(enforceScript).toContain('download.kanary.settings'); + expect(enforceScript).toContain('capsLockRemappedToControl'); + expect(adr).toContain('kanary-enforce-caps-control'); + }); + test('portable user dotfiles are managed without credential state', () => { const dotfilesModule = readRepoFile('nix/home/dotfiles.nix');