Free the Copy URL shortcut from ghost extension registrations (#6821)

* Rebind ghost Copy URL shortcut registrations to the pinned id

Chromium never hands a suggested shortcut to one extension while
another — even a long-gone one — still holds the registration. Profiles
that first loaded Copy URL before its id was pinned registered
Alt+Shift+L under an id derived from the extension's load path at the
time, so the pinned extension never receives the shortcut and the
keypress does nothing (#6816).

The quattro upgrade tried to repair this against one hardcoded
path-derived id, which only ever matched a single home directory. The
historical ids are unknowable in general — they hash long-gone absolute
paths through whatever symlinks existed then — but the registration
itself names the command, so a migration now rebinds any copy-url
command that points away from the pinned id, unless that id belongs to
an extension that is actually installed or the pinned extension already
holds a binding of its own.

Browsers rewrite Preferences on exit, which reverts any repair made
while one runs, so the migration asks for this user's browser windows to
be closed first — failing and staying pending when there is no terminal
to ask in or the prompt is declined. The backup a repair leaves behind
marks it as attempted but unverified: until a browser-free run confirms
the registration stayed repaired, the migration keeps itself pending
rather than trusting a disk state an open browser may still overwrite.

The upgrade-time repair is dropped: the upgrade already runs migrations,
so the migration is the single implementation.

Fixes #6816

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pin the WhatsApp Slim extension id

Keyless unpacked extensions get path-derived ids, which go stale if the
load path or packaging ever changes — the same class of bug that broke
the Copy URL shortcut for pre-package installs. Pin the id with a
manifest key like the other bundled extensions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-13 22:37:29 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 30f8f191c0
commit de854d3f0c
6 changed files with 399 additions and 113 deletions
-77
View File
@@ -1792,82 +1792,6 @@ CHROMIUM_FLAGS_PATCH_PY
done
}
# True if any Chromium-family browser that stores the Copy URL shortcut is running.
browser_is_running() {
pgrep -x 'chromium|chrome|brave|msedge|vivaldi-bin|vivaldi|opera|helium' >/dev/null 2>&1
}
repair_chromium_copy_url_shortcuts() {
local profile_root preferences
local -a profile_roots=(
"$HOME/.config/chromium"
"$HOME/.config/google-chrome"
"$HOME/.config/google-chrome-beta"
"$HOME/.config/BraveSoftware/Brave-Browser"
"$HOME/.config/BraveSoftware/Brave-Browser-Beta"
"$HOME/.config/microsoft-edge"
"$HOME/.config/microsoft-edge-beta"
"$HOME/.config/vivaldi"
"$HOME/.config/opera"
"$HOME/.config/helium"
)
local -a pending=()
for profile_root in "${profile_roots[@]}"; do
[[ -d $profile_root ]] || continue
for preferences in "$profile_root"/*/Preferences; do
[[ -f $preferences ]] || continue
grep -qF 'bocglpkldciamkbmlphanhkfnhpmnbma' "$preferences" || continue
pending+=("$preferences")
done
done
(( ${#pending[@]} )) || return 0
# A running browser holds Preferences in memory and rewrites it on exit,
# which would revert this repair. Ask the user to close it first.
if browser_is_running && (( ! OMARCHY_UPGRADE_YES )) && [[ -r /dev/tty ]]; then
if ! gum confirm "Close all browser windows before the Copy URL shortcut is repaired, then continue" </dev/tty; then
echo "Skipped Copy URL shortcut repair; re-run after closing your browser." >&2
return 0
fi
fi
for preferences in "${pending[@]}"; do
python3 - "$preferences" "$preferences.omarchy-upgrade-to-quattro.$BACKUP_SUFFIX.bak" <<'CHROMIUM_SHORTCUTS_PATCH_PY'
import json
import shutil
import sys
from pathlib import Path
path = Path(sys.argv[1])
backup_path = Path(sys.argv[2])
old_id = "bocglpkldciamkbmlphanhkfnhpmnbma"
new_id = "bgpiichlckmfanooecilcjemknkcpngb"
preferences = json.loads(path.read_text())
extensions = preferences.get("extensions", {})
commands = extensions.get("commands", {})
changed = False
for command in commands.values():
if command.get("extension") == old_id and command.get("command_name") == "copy-url":
command["extension"] = new_id
changed = True
if changed:
settings = extensions.get("settings", {})
old_command = settings.get(old_id, {}).get("commands", {}).get("copy-url", {})
new_command = settings.get(new_id, {}).get("commands", {}).get("copy-url", {})
old_command.pop("was_assigned", None)
if new_command:
new_command["was_assigned"] = True
shutil.copy2(path, backup_path)
path.write_text(json.dumps(preferences, separators=(",", ":")))
CHROMIUM_SHORTCUTS_PATCH_PY
done
}
repair_sleep_lock_unit_override() {
local unit="$HOME/.config/systemd/user/omarchy-sleep-lock.service"
local legacy_exec='ExecStart=%h/.local/share/omarchy/bin/omarchy-system-sleep-monitor'
@@ -1964,7 +1888,6 @@ copy_missing_config_defaults "$root/config" "$HOME/.config"
mark_removed_preinstalls_from_legacy_bindings
copy_always_config_defaults
repair_chromium_copy_url_extension_flags
repair_chromium_copy_url_shortcuts
# Carry over user-added backgrounds from the legacy git checkout backup, then
# remove old symlinks that pointed ~/.config/omarchy/themes/* back into the
@@ -2,12 +2,19 @@
"manifest_version": 3,
"name": "WhatsApp Slim",
"version": "1.1",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAszLhFYxjvKqx1VAORt3M6b0H1Kz03cmQ1MrUjJZ8gj3nhRcvufk6PS17yUIjEdKKmuj+1HuFVwOaHJQsACGJXwn2TeKQqZmZx/83yd7ydgabHXTsJbhy5O+Yunv+VA1B2IX/phcXpkH9bPv2PuC/bQ13nmmAvT2b+EMyq8EPQHOoSWhE4M4kIhLDczTmxfJX5X3tTxH66952DYuQWvGpIXLAUXkvIgap0qemW68/LymPaWzcg6g3oE/nZ7C0j7JE5bnqLFxqXNlA9Jt+Ss/1Y33QL3OSD9EEsrpgueS0bbqtkmFQ9/jGlruIL+qj0xe+CAtqbJ5ty9H/sqi01kjfyQIDAQAB",
"description": "Make WhatsApp Web follow the system theme and collapse its chat list in narrow windows, this extension is installed by Omarchy",
"content_scripts": [
{
"matches": ["https://web.whatsapp.com/*"],
"css": ["whatsapp.css"],
"js": ["system-theme.js"],
"matches": [
"https://web.whatsapp.com/*"
],
"css": [
"whatsapp.css"
],
"js": [
"system-theme.js"
],
"run_at": "document_start"
}
]
+176
View File
@@ -0,0 +1,176 @@
echo "Repair the Copy URL shortcut for profiles that predate its pinned extension id"
# Chromium never hands a suggested shortcut to one extension while another —
# even a long-gone one — still holds the registration. Profiles that first
# loaded Copy URL before its id was pinned registered Alt+Shift+L under an id
# derived from the extension's load path at the time, so the pinned extension
# never receives the shortcut and the keypress does nothing. Those historical
# ids are unknowable in general (they hash long-gone absolute paths), but the
# registration itself names the command: rebind any copy-url command that
# points away from the pinned id, unless that id belongs to an extension that
# is actually installed.
pinned_id="bgpiichlckmfanooecilcjemknkcpngb"
repair_py=$(cat <<'PY'
import json
import shutil
import sys
from pathlib import Path
path = Path(sys.argv[1])
pinned_id = sys.argv[2]
mode = sys.argv[3]
try:
preferences = json.loads(path.read_text())
except (OSError, ValueError):
sys.exit(1)
extensions = preferences.get("extensions", {})
commands = extensions.get("commands", {})
settings = extensions.get("settings", {})
def ghost_id(command):
if command.get("command_name") != "copy-url":
return None
extension = command.get("extension")
if not extension or extension == pinned_id:
return None
# An installed extension records its install path in settings; only the
# ghosts of Copy URL itself may be rebound.
install_path = settings.get(extension, {}).get("path", "")
if install_path and not install_path.endswith("/default/chromium/extensions/copy-url"):
return None
return extension
if mode == "check":
sys.exit(0 if any(ghost_id(c) for c in commands.values()) else 1)
# Chromium keeps one shortcut per command, so a ghost may only be rebound
# when the pinned extension holds no copy-url binding of its own; further
# ghosts are dropped rather than doubled up.
pinned_bound = any(
c.get("command_name") == "copy-url" and c.get("extension") == pinned_id
for c in commands.values()
)
changed = False
for accelerator in list(commands):
ghost = ghost_id(commands[accelerator])
if not ghost:
continue
if pinned_bound:
del commands[accelerator]
else:
commands[accelerator]["extension"] = pinned_id
pinned_bound = True
pinned_command = settings.get(pinned_id, {}).get("commands", {}).get("copy-url", {})
if pinned_command:
pinned_command["was_assigned"] = True
settings.pop(ghost, None)
changed = True
if changed:
shutil.copy2(path, path.with_name(path.name + ".omarchy-copy-url-repair.bak"))
path.write_text(json.dumps(preferences, separators=(",", ":")))
PY
)
profile_roots=(
"$HOME/.config/chromium"
"$HOME/.config/google-chrome"
"$HOME/.config/google-chrome-beta"
"$HOME/.config/google-chrome-unstable"
"$HOME/.config/BraveSoftware/Brave-Browser"
"$HOME/.config/BraveSoftware/Brave-Browser-Beta"
"$HOME/.config/BraveSoftware/Brave-Browser-Nightly"
"$HOME/.config/microsoft-edge"
"$HOME/.config/microsoft-edge-beta"
"$HOME/.config/microsoft-edge-dev"
"$HOME/.config/vivaldi"
"$HOME/.config/opera"
"$HOME/.config/helium"
)
find_pending() {
pending=()
for profile_root in "${profile_roots[@]}"; do
[[ -d $profile_root ]] || continue
for preferences in "$profile_root"/*/Preferences; do
[[ -f $preferences ]] || continue
python3 -c "$repair_py" "$preferences" "$pinned_id" check || continue
pending+=("$preferences")
done
done
}
# The backup a repair leaves next to Preferences marks it as attempted but not
# yet verified: a browser that was open during the repair still holds the
# stale registration in memory and restores it when it exits, possibly after
# the disk was already seen clean.
unverified_repairs_exist() {
local profile_root backup
for profile_root in "${profile_roots[@]}"; do
for backup in "$profile_root"/*/Preferences.omarchy-copy-url-repair.bak; do
[[ -f $backup ]] && return 0
done
done
return 1
}
# Only this user's browsers matter — another user's cannot rewrite these
# Preferences.
browsers_running() {
pgrep -x -u "$UID" 'chromium|chrome|brave|msedge|vivaldi-bin|vivaldi|opera|helium' >/dev/null 2>&1
}
find_pending
if (( ! ${#pending[@]} )) && ! unverified_repairs_exist; then
exit 0
fi
# A running browser holds Preferences in memory and rewrites the file on exit,
# reverting the repair, so ask for the windows to be closed first. Without a
# terminal to ask in, gum fails; then — as on decline — fail so the migration
# stays pending, and the login notifier keeps prompting until a rerun goes
# through with browsers closed.
while browsers_running; do
if ! gum confirm "Close all browser windows to repair the Copy URL shortcut, then continue" 2>/dev/null; then
echo "A running browser would undo the Copy URL shortcut repair." >&2
echo "Close all browser windows, then run: omarchy-migrate" >&2
exit 1
fi
done
# A browser that just closed may have restored ghosts an earlier repair had
# already removed, so only now is the pending list authoritative.
find_pending
for preferences in "${pending[@]}"; do
python3 -c "$repair_py" "$preferences" "$pinned_id" repair
done
# A browser that started mid-repair read the stale Preferences and will write
# them back on exit; stay pending so the next browser-free run can verify the
# repair stuck.
if browsers_running; then
echo "A browser started during the Copy URL shortcut repair and may undo it on exit." >&2
echo "Close all browser windows, then run: omarchy-migrate" >&2
exit 1
fi
# One that started and already exited mid-repair wrote its stale in-memory
# Preferences back over the repair; verify every file again. A browser
# starting after this point reads the repaired file, so it can no longer
# restore the ghost.
for preferences in "${pending[@]}"; do
if python3 -c "$repair_py" "$preferences" "$pinned_id" check; then
echo "A browser undid the Copy URL shortcut repair on exit." >&2
echo "Close all browser windows, then run: omarchy-migrate" >&2
exit 1
fi
done
-33
View File
@@ -104,36 +104,3 @@ native_reply=$(bash -c '
[[ $native_reply == "0f0000007b22636f70696564223a747275657d" ]] ||
fail "copy-url native host returns a framed success response" "$native_reply"
pass "copy-url native host returns a framed success response"
preferences="$TMPDIR/Preferences"
backup="$TMPDIR/Preferences.bak"
patch_script="$TMPDIR/repair-shortcuts.py"
awk '
/<<'\''CHROMIUM_SHORTCUTS_PATCH_PY'\''/ { copying = 1; next }
copying && $0 == "CHROMIUM_SHORTCUTS_PATCH_PY" { exit }
copying { print }
' "$ROOT/bin/omarchy-upgrade-to-quattro" >"$patch_script"
cat >"$preferences" <<'JSON'
{"extensions":{"commands":{"linux:Alt+Shift+L":{"command_name":"copy-url","extension":"bocglpkldciamkbmlphanhkfnhpmnbma","global":false},"linux:Alt+Shift+D":{"command_name":"download-video","extension":"dedjgknigfeelejglamclffonmophnfl","global":false}},"settings":{"bocglpkldciamkbmlphanhkfnhpmnbma":{"commands":{"copy-url":{"suggested_key":"Alt+Shift+L","was_assigned":true}}},"bgpiichlckmfanooecilcjemknkcpngb":{"commands":{"copy-url":{"suggested_key":"Alt+Shift+L"}}}}}}
JSON
python3 "$patch_script" "$preferences" "$backup"
jq -e '
.extensions.commands["linux:Alt+Shift+L"].extension == "bgpiichlckmfanooecilcjemknkcpngb" and
.extensions.commands["linux:Alt+Shift+D"].extension == "dedjgknigfeelejglamclffonmophnfl" and
(.extensions.settings.bocglpkldciamkbmlphanhkfnhpmnbma.commands["copy-url"] | has("was_assigned") | not) and
.extensions.settings.bgpiichlckmfanooecilcjemknkcpngb.commands["copy-url"].was_assigned == true
' "$preferences" >/dev/null || fail "quattro upgrade moves the Copy URL shortcut to the stable extension id"
cmp -s "$backup" <(printf '%s\n' '{"extensions":{"commands":{"linux:Alt+Shift+L":{"command_name":"copy-url","extension":"bocglpkldciamkbmlphanhkfnhpmnbma","global":false},"linux:Alt+Shift+D":{"command_name":"download-video","extension":"dedjgknigfeelejglamclffonmophnfl","global":false}},"settings":{"bocglpkldciamkbmlphanhkfnhpmnbma":{"commands":{"copy-url":{"suggested_key":"Alt+Shift+L","was_assigned":true}}},"bgpiichlckmfanooecilcjemknkcpngb":{"commands":{"copy-url":{"suggested_key":"Alt+Shift+L"}}}}}}') ||
fail "quattro upgrade backs up Chromium preferences before shortcut repair"
pass "quattro upgrade repairs and backs up the Copy URL shortcut"
unchanged_hash=$(sha256sum "$preferences" | cut -d' ' -f1)
rm "$backup"
python3 "$patch_script" "$preferences" "$backup"
[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$unchanged_hash" && ! -e $backup ]] ||
fail "Copy URL shortcut repair is idempotent"
pass "Copy URL shortcut repair is idempotent"
@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
require_command node
# A keyless unpacked extension gets a path-derived id, so its settings — and
# any shortcut registrations it may grow — would go stale if its load path or
# packaging ever changed. The pinned key keeps the id stable everywhere.
whatsapp_slim_id=$(node - <<'JS' "$ROOT/default/chromium/extensions/whatsapp-slim/manifest.json"
const crypto = require('crypto')
const fs = require('fs')
const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))
const hash = crypto.createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest()
const alphabet = 'abcdefghijklmnop'
let id = ''
for (const byte of hash.subarray(0, 16)) {
id += alphabet[byte >> 4]
id += alphabet[byte & 0x0f]
}
process.stdout.write(id)
JS
)
[[ $whatsapp_slim_id == "amhpgjbcfakkmkeojmoaoiochhifohdi" ]] ||
fail "whatsapp-slim extension manifest has the stable id" "$whatsapp_slim_id"
pass "whatsapp-slim extension manifest has the stable id"
@@ -0,0 +1,181 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
require_command jq
require_command python3
migration="$ROOT/migrations/1786643346.sh"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
home="$test_dir/home"
preferences="$home/.config/chromium/Default/Preferences"
mkdir -p "$(dirname "$preferences")"
# Any id Chromium once derived from the extension's keyless load path; the
# repair keys off the registered command name, not the id.
ghost_id="ikkebdkaanlebnifjnbeiaklodhbjcci"
pinned_id="bgpiichlckmfanooecilcjemknkcpngb"
write_stale_preferences() {
jq -n --arg ghost "$ghost_id" --arg pinned "$pinned_id" '{extensions: {commands: {"linux:Alt+Shift+L": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {($ghost): {commands: {"copy-url": {suggested_key: "Alt+Shift+L", was_assigned: true}}}, ($pinned): {commands: {"copy-url": {suggested_key: "Alt+Shift+L"}}}}}}' >"$preferences"
}
stub_bin="$test_dir/bin"
mkdir -p "$stub_bin"
run_migration() {
HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1
}
# A running browser prompts for the windows to be closed; declining (or having
# no terminal to ask in) defers the repair so a rewrite-on-exit cannot revert
# it.
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/pgrep"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum"
chmod +x "$stub_bin/pgrep" "$stub_bin/gum"
write_stale_preferences
before_hash=$(sha256sum "$preferences" | cut -d' ' -f1)
run_migration && fail "migration defers while a browser is running"
[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$before_hash" ]] ||
fail "migration leaves preferences alone while a browser is running"
pass "migration defers the repair while a browser is running"
# Confirming the prompt after closing the browser lets the repair proceed.
cat >"$stub_bin/gum" <<'STUB'
#!/bin/bash
touch "${GUM_CALLED:?}"
exit 0
STUB
cat >"$stub_bin/pgrep" <<'STUB'
#!/bin/bash
count_file="${PGREP_COUNT_FILE:?}"
count=$(( $(cat "$count_file" 2>/dev/null || echo 0) + 1 ))
printf '%s\n' "$count" >"$count_file"
(( count == 1 )) && exit 0 || exit 1
STUB
rm -f "$test_dir/pgrep-count"
GUM_CALLED="$test_dir/gum-called" PGREP_COUNT_FILE="$test_dir/pgrep-count" \
HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 ||
fail "migration proceeds once the browser prompt is confirmed"
[[ -e $test_dir/gum-called ]] || fail "migration asks before repairing under a running browser"
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null ||
fail "migration repairs after the browser prompt is confirmed"
pass "migration asks to close the browser and repairs on confirmation"
rm -f "$preferences.omarchy-copy-url-repair.bak"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep"
write_stale_preferences
# With browsers closed the ghost registration moves to the pinned id.
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep"
run_migration || fail "migration repairs the shortcut when no browser is running"
jq -e --arg ghost "$ghost_id" --arg pinned "$pinned_id" '
.extensions.commands["linux:Alt+Shift+L"].extension == $pinned and
(.extensions.settings | has($ghost) | not) and
.extensions.settings[$pinned].commands["copy-url"].was_assigned == true
' "$preferences" >/dev/null || fail "migration rebinds the Copy URL shortcut to the pinned extension id"
[[ -f $preferences.omarchy-copy-url-repair.bak ]] ||
fail "migration backs up preferences before the repair"
pass "migration rebinds the Copy URL shortcut to the pinned extension id"
# A repaired profile has no ghost registration left, so nothing is pending —
# even while a browser is running.
rm "$preferences.omarchy-copy-url-repair.bak"
repaired_hash=$(sha256sum "$preferences" | cut -d' ' -f1)
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/pgrep"
run_migration || fail "migration reruns cleanly after the repair"
[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$repaired_hash" && ! -e $preferences.omarchy-copy-url-repair.bak ]] ||
fail "migration is idempotent after the repair"
pass "migration is idempotent after the repair"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep"
# A remapped shortcut keeps the user's chosen key while moving to the pinned id.
jq -n --arg ghost "$ghost_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences"
run_migration || fail "migration repairs remapped shortcuts"
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Ctrl+Alt+P"].extension == $pinned' "$preferences" >/dev/null ||
fail "migration keeps the remapped key while rebinding to the pinned id"
pass "migration keeps remapped shortcut keys"
# When the pinned extension already holds a copy-url binding (the user fixed
# it by hand), the ghost is dropped rather than doubled into a second binding.
jq -n --arg ghost "$ghost_id" --arg pinned "$pinned_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $pinned, global: false}, "linux:Alt+Shift+L": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences"
run_migration || fail "migration cleans ghosts alongside a manual repair"
jq -e --arg pinned "$pinned_id" '
(.extensions.commands | has("linux:Alt+Shift+L") | not) and
.extensions.commands["linux:Ctrl+Alt+P"].extension == $pinned
' "$preferences" >/dev/null || fail "migration drops the ghost instead of double-binding the pinned extension"
pass "migration never double-binds the pinned extension"
# A browser starting mid-repair may write stale Preferences back on exit, so
# the migration must stay pending for a later browser-free run to verify.
write_stale_preferences
cat >"$stub_bin/pgrep" <<'STUB'
#!/bin/bash
count_file="${PGREP_COUNT_FILE:?}"
count=$(( $(cat "$count_file" 2>/dev/null || echo 0) + 1 ))
printf '%s\n' "$count" >"$count_file"
(( count >= 2 )) && exit 0 || exit 1
STUB
rm -f "$test_dir/pgrep-count"
if PGREP_COUNT_FILE="$test_dir/pgrep-count" HOME="$home" PATH="$stub_bin:$PATH" \
bash -euo pipefail "$migration" >/dev/null 2>&1; then
fail "migration stays pending when a browser starts mid-repair"
fi
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null ||
fail "migration still repairs preferences before deferring on a late browser"
pass "migration stays pending when a browser starts mid-repair"
rm -f "$preferences.omarchy-copy-url-repair.bak"
# A browser that started and exited mid-repair restores stale Preferences
# before the final process check; the post-repair file verification catches it.
write_stale_preferences
cp "$preferences" "$test_dir/stale-preferences"
cat >"$stub_bin/pgrep" <<'STUB'
#!/bin/bash
count_file="${PGREP_COUNT_FILE:?}"
count=$(( $(cat "$count_file" 2>/dev/null || echo 0) + 1 ))
printf '%s\n' "$count" >"$count_file"
(( count == 2 )) && cp "$STALE_PREFERENCES" "$REPAIRED_PREFERENCES"
exit 1
STUB
rm -f "$test_dir/pgrep-count"
if PGREP_COUNT_FILE="$test_dir/pgrep-count" STALE_PREFERENCES="$test_dir/stale-preferences" \
REPAIRED_PREFERENCES="$preferences" HOME="$home" PATH="$stub_bin:$PATH" \
bash -euo pipefail "$migration" >/dev/null 2>&1; then
fail "migration stays pending when a briefly-lived browser undoes the repair"
fi
pass "migration stays pending when a briefly-lived browser undoes the repair"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep"
write_stale_preferences
run_migration || fail "migration recovers after a reverted repair"
rm -f "$preferences.omarchy-copy-url-repair.bak"
# A repair attempted while a browser was open leaves its backup behind. A
# rerun that sees a clean disk while that browser still runs must stay
# pending — the browser can restore the ghost on exit — and only a
# browser-free rerun verifies the repair and completes.
write_stale_preferences
run_migration || fail "repair run before the verification scenario"
[[ -f $preferences.omarchy-copy-url-repair.bak ]] || fail "verification scenario has a repair backup"
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/pgrep"
run_migration && fail "migration must not complete an unverified repair while a browser runs"
pass "migration keeps an unverified repair pending while a browser runs"
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/pgrep"
run_migration || fail "migration completes once the repair is verified with browsers closed"
pass "migration verifies an attempted repair on a browser-free rerun"
rm -f "$preferences.omarchy-copy-url-repair.bak"
# An installed third-party extension with a command that happens to be named
# copy-url keeps its own registration.
jq -n '{extensions: {commands: {"linux:Alt+Shift+L": {command_name: "copy-url", extension: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", global: false}}, settings: {aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {path: "/home/user/.config/some-extension", commands: {}}}}}' >"$preferences"
untouched_hash=$(sha256sum "$preferences" | cut -d' ' -f1)
run_migration || fail "migration leaves installed third-party extensions alone"
[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$untouched_hash" ]] ||
fail "migration does not steal a third-party copy-url command registration"
pass "migration leaves installed third-party extensions alone"