From cd65e0cdc95726e75c15f514ea6daf8b21b1b1d9 Mon Sep 17 00:00:00 2001 From: Sebastian Nagel Date: Tue, 13 Jan 2026 03:19:44 +0100 Subject: [PATCH] feat: declarative keyboard shortcut overrides (#199) Resolves https://github.com/0xc000022070/zen-browser-flake/issues/138. --- .github/README.md | 65 ++++++++++++++ hm-module.nix | 209 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) diff --git a/.github/README.md b/.github/README.md index a57e865..e0f49c0 100644 --- a/.github/README.md +++ b/.github/README.md @@ -12,6 +12,7 @@ This is a nix flake for the Zen browser. - Browser update checks are disabled by default - The default twilight version is reliable and reproducible - [Declarative \[Work\]Spaces (including themes, icons, containers)](#spaces) +- [Declarative keyboard shortcuts with version protection](#keyboard-shortcuts) ## Installation @@ -181,6 +182,7 @@ Check - [bookmarks](#bookmarks) - [spaces](#spaces) - [pinned tabs](#pinned-tabs-pins) + - [keyboard shortcuts](#keyboard-shortcuts) - [userChrome](#userchromecss) ### Extensions @@ -528,6 +530,69 @@ You are also able to declare your pinned tabs! For more info, see } ``` +### Keyboard Shortcuts + +Declarative overrides of Zen Browser's keyboard shortcuts with version protection against breaking changes. + +```nix +{ + programs.zen-browser.profiles.default = { + keyboardShortcuts = [ + # Change compact mode toggle to Ctrl+Alt+S + { + id = "zen-compact-mode-toggle"; + key = "s"; + modifiers = { + control = true; + alt = true; + }; + } + # Disable the quit shortcut to prevent accidental closes + { + id = "key_quitApplication"; + disabled = true; + } + ]; + # Fails activation on schema changes to detect potential regressions + # Find this in about:config or prefs.js of your profile + keyboardShortcutsVersion = 14; + }; +} +``` + +When you declare a shortcut override: + +- Identity fields (`id`, `group`, `action`, `l10nId`, `reserved`, `internal`) are preserved from Zen's defaults +- Binding fields (`key`, `keycode`, `modifiers`, `disabled`) are completely replaced with your declaration + +#### Configuration Options + +- `profiles.*.keyboardShortcuts` (list of submodules): Declarative keyboard shortcuts configuration. + - `id` (string) **Required.** Unique identifier for the shortcut to modify. + - `key` (null or string) Character key (e.g., "a", "1", "+"). Leave null to use default. + - `keycode` (null or string) Virtual key code for special keys (e.g., "VK_F1", "VK_DELETE"). Leave null to use default. + - `disabled` (null or boolean) Set to true to disable the shortcut. Leave null to use default. + - `modifiers` (null or submodule) Modifier keys configuration. Leave null to use defaults. + - `control` (null or boolean) Ctrl key modifier. + - `alt` (null or boolean) Alt key modifier. + - `shift` (null or boolean) Shift key modifier. + - `meta` (null or boolean) Meta/Windows/Command key modifier. + - `accel` (null or boolean) Accelerator key (Ctrl on Linux/Windows, Cmd on macOS). + +- `profiles.*.keyboardShortcutsVersion` (null or integer) Expected version of the keyboard shortcuts schema. If set, activation will fail if the Zen Browser shortcuts version doesn't match, preventing silent breakage after Zen Browser updates. Find the current version in `about:config` as `zen.keyboard.shortcuts.version`. + +### Finding Shortcut IDs + +Find all shortcuts in `~/.zen//zen-keyboard-shortcuts.json`. For example: + +```bash +jq -c '.shortcuts[] | {id, key, keycode, action}' ~/.zen/default/zen-keyboard-shortcuts.json +``` + +### Notes on activation + +Keyboard shortcuts are still managed by Zen and the home manager module only overrides them on activation. That means, that zen needs to be started at least once to create the shortcuts file if it doesn't exist yet. Then, every rebuild of your configuration (`nixos-rebuild switch` or `home-manager switch`) will apply your keybindings. Also note that you can just re-run activation scripts with `systemctl start home-manager-${USER}.service`. + ### userChrome.css ```nix diff --git a/hm-module.nix b/hm-module.nix index 24d593b..6b9a834 100644 --- a/hm-module.nix +++ b/hm-module.nix @@ -278,6 +278,85 @@ in { ); default = {}; }; + keyboardShortcuts = mkOption { + type = listOf ( + submodule ( + {...}: { + options = { + id = mkOption { + type = str; + description = "Unique identifier for the keyboard shortcut to modify."; + }; + key = mkOption { + type = nullOr str; + default = null; + description = "The character key (e.g., 'a', 's', '1'). Leave null to keep existing value."; + }; + keycode = mkOption { + type = nullOr str; + default = null; + description = "Virtual key code (e.g., 'VK_F1', 'VK_DELETE'). Leave null to keep existing value."; + }; + modifiers = mkOption { + type = nullOr (submodule { + options = { + control = mkOption { + type = nullOr bool; + default = null; + description = "Ctrl key modifier."; + }; + alt = mkOption { + type = nullOr bool; + default = null; + description = "Alt key modifier."; + }; + shift = mkOption { + type = nullOr bool; + default = null; + description = "Shift key modifier."; + }; + meta = mkOption { + type = nullOr bool; + default = null; + description = "Meta/Windows/Command key modifier."; + }; + accel = mkOption { + type = nullOr bool; + default = null; + description = "Accelerator key (Ctrl on Windows/Linux, Cmd on macOS)."; + }; + }; + }); + default = null; + description = "Modifier keys for the shortcut. Leave null to keep existing values."; + }; + disabled = mkOption { + type = nullOr bool; + default = null; + description = "Whether the shortcut is disabled. Leave null to keep existing value."; + }; + }; + } + ) + ); + default = []; + description = '' + Declarative keyboard shortcuts configuration. + Each item specifies a shortcut to modify by its id. + Only the fields you specify will be overridden; others keep their defaults from Zen. + ''; + }; + keyboardShortcutsVersion = mkOption { + type = nullOr int; + default = null; + example = 1; + description = '' + Expected version of the keyboard shortcuts schema. + If set, activation will fail if the Zen Browser shortcuts version doesn't match, + preventing silent breakage after Zen Browser updates. + Find the current version in about:config as "zen.keyboard.shortcuts.version". + ''; + }; }; } ) @@ -656,5 +735,135 @@ in { force = true; } ) (filterAttrs (_: profile: profile.spaces != {} || profile.spacesForce || profile.pins != {} || profile.pinsForce) cfg.profiles)); + + home.activation = + let + inherit (builtins) toJSON; + inherit + (lib) + filterAttrs + mapAttrs' + nameValuePair + optionalString; + # Filter profiles that have keyboard shortcuts configured + profilesWithShortcuts = filterAttrs + (_: profile: profile.keyboardShortcuts != [ ]) + cfg.profiles; + + in + (mapAttrs' + (profileName: profile: + let + shortcutsFile = "${profilePath}/${profileName}/zen-keyboard-shortcuts.json"; + shortcutsFilePath = "${config.home.homeDirectory}/${shortcutsFile}"; + prefsFile = "${config.home.homeDirectory}/${profilePath}/${profileName}/prefs.js"; + + # Convert Nix shortcut config to JSON format + # All binding fields are included (with null/false defaults) to fully replace the binding + shortcutToJson = shortcut: { + inherit (shortcut) id; + key = if shortcut.key != null then shortcut.key else ""; + keycode = shortcut.keycode; + modifiers = if shortcut.modifiers != null then shortcut.modifiers else { + control = false; + alt = false; + shift = false; + meta = false; + accel = false; + }; + disabled = if shortcut.disabled != null then shortcut.disabled else false; + }; + + # Generate the shortcuts overrides array + declaredShortcuts = map shortcutToJson profile.keyboardShortcuts; + + # Script to update shortcuts + updateScript = pkgs.writeShellScript "zen-shortcuts-update-${profileName}" '' + SHORTCUTS_FILE="${shortcutsFilePath}" + PREFS_FILE="${prefsFile}" + OVERRIDES='${toJSON declaredShortcuts}' + + # Wait for Zen to create the shortcuts file if it doesn't exist yet + if [ ! -f "$SHORTCUTS_FILE" ]; then + echo "zen-keyboard-shortcuts: Shortcuts file doesn't exist yet at $SHORTCUTS_FILE" + echo "zen-keyboard-shortcuts: Zen Browser will create it on first run" + exit 0 + fi + + ${optionalString (profile.keyboardShortcutsVersion != null) '' + # Version check: ensure shortcuts schema matches expected version + if [ -f "$PREFS_FILE" ]; then + ACTUAL_VERSION=$(${pkgs.gnugrep}/bin/grep -oP 'user_pref\("zen\.keyboard\.shortcuts\.version",\s*\K\d+' "$PREFS_FILE" || echo "") + EXPECTED_VERSION="${toString profile.keyboardShortcutsVersion}" + + if [ -n "$ACTUAL_VERSION" ] && [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then + echo "ERROR: Zen Browser keyboard shortcuts version mismatch!" + echo " Expected version: $EXPECTED_VERSION" + echo " Actual version: $ACTUAL_VERSION" + echo "" + echo "This likely means Zen Browser was updated and keyboard shortcuts changed." + echo "To fix this:" + echo " 1. Check the new shortcuts in settings or ${shortcutsFilePath}" + echo " 2. Review and update your keyboard shortcuts overrides if needed" + echo " 3. Update keyboardShortcutsVersion = $ACTUAL_VERSION in your configuration" + exit 1 + fi + fi + ''} + + # Read existing shortcuts + EXISTING_SHORTCUTS=$(cat "$SHORTCUTS_FILE") + + # Use jq to merge overrides into existing shortcuts + # For each override, preserve identity fields but completely replace binding fields + MERGED=$(echo "$EXISTING_SHORTCUTS" | ${pkgs.jq}/bin/jq --argjson overrides "$OVERRIDES" ' + .shortcuts |= map( + . as $existing | + # Find if there is an override for this shortcut + ($overrides | map(select(.id == $existing.id)) | .[0]) as $override | + if $override then + # Preserve identity/metadata fields from existing + { + id: $existing.id, + group: $existing.group, + l10nId: $existing.l10nId, + action: $existing.action, + reserved: $existing.reserved, + internal: $existing.internal + } + # Replace binding fields with override + + { + key: $override.key, + keycode: $override.keycode, + modifiers: $override.modifiers, + disabled: $override.disabled + } + else + # No override, keep as is + $existing + end + ) + ') + + echo "$MERGED" > "$SHORTCUTS_FILE" + + # Validate JSON + if ! ${pkgs.jq}/bin/jq empty "$SHORTCUTS_FILE" 2>/dev/null; then + echo "Error: Generated invalid JSON in $SHORTCUTS_FILE" + exit 1 + fi + ''; + + in + nameValuePair "zen-keyboard-shortcuts-${profileName}" (lib.hm.dag.entryAfter [ "writeBoundary" ] '' + ${updateScript} + if [[ "$?" -eq 0 ]]; then + $VERBOSE_ECHO "zen-keyboard-shortcuts: Updated keyboard shortcuts for profile '${profileName}'" + else + echo "zen-keyboard-shortcuts: Failed to update keyboard shortcuts for profile '${profileName}'!" >&2 + fi + '') + ) + profilesWithShortcuts); }; }