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
30 changes: 24 additions & 6 deletions docs/adr/0016-use-kanary-for-keyboard-remapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions nix/home/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
./dotfiles.nix
./agent-commands.nix
./input-source.nix
./kanary.nix
./cmux.nix
];

Expand Down
25 changes: 25 additions & 0 deletions nix/home/kanary.nix
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +22 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the Kanary hook after home-file linking

On the first darwin-rebuild switch that includes this module, the target in ~/.local/bin has not necessarily been created yet: Home Manager links home.file entries in its linkGeneration activation step, which is also only ordered after writeBoundary, so this new step has no dependency on the link being present. Because the command's failure is swallowed with || true, the initial activation can silently leave capsLockRemappedToControl unchanged until a later switch; order this after linkGeneration or invoke the store path directly.

Useful? React with 👍 / 👎.

'';
}
94 changes: 94 additions & 0 deletions script/macos/kanary-enforce-caps-control.sh
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +35 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve macOS utilities instead of activation PATH entries

When this helper is invoked from Home Manager activation, this repo's home.stateVersion = "24.11" means activation starts with a restricted PATH containing Nix tools, not /usr/bin. In that context plutil/python3/later defaults may not be found, and base64 resolves to GNU coreutils, whose help lists decoding as -d, --decode rather than macOS -D; for any activation where the Kanary setting is false, the script will skip or fail before writing while the activation masks the failure. Use absolute /usr/bin/... paths or decode/update the plist entirely via a known Nix/Python dependency.

Useful? React with 👍 / 👎.

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"
14 changes: 14 additions & 0 deletions test/nix-darwin-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Loading