diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d42b1a9..13b5d63 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -122,7 +122,8 @@ "mcp__azure-devops__pipelines_get_run", "mcp__atlassian__getJiraIssue", "mcp__atlassian__searchJiraIssuesUsingJql", - "Bash(gh:*)" + "Bash(gh:*)", + "WebSearch" ], "deny": [ "Bash(git push*main*)", diff --git a/Makefile b/Makefile index d2d07bf..431aadd 100644 --- a/Makefile +++ b/Makefile @@ -167,6 +167,14 @@ jetbrains: ## set the JVM options this repo owns in every JetBrains config dir @echo @echo "plugins: open $(CURDIR) in the IDE and accept the 'required plugins' prompt" +# +# Google Chrome — the toggles Google does not sync, see bin/chrome/prefs.sh +# + +.PHONY: chrome +chrome: ## apply the Chrome settings that do not sync (vertical tabs, side panel) + ./bin/chrome/prefs.sh + # # macOS System Settings — the panes stow cannot reach, see bin/macos/defaults.sh # diff --git a/README.md b/README.md index 524d7fd..2323e8f 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,15 @@ make git # hook in .gitconfig, set email + commit signing make macos # macOS only: menu bar, Dock, Finder, trackpad, formats make macos-touchid # macOS only: authenticate sudo with Touch ID (asks for root) make jetbrains # set the IDE heap; then open this repo in the IDE to get the plugins +make chrome # Chrome's non-syncing toggles — quit Chrome first ``` +`make chrome` covers the handful of Chrome settings that stay on the machine +instead of following the Google account: vertical tabs and which side the side +panel opens on. Chrome rewrites `Default/Preferences` when it exits, so the +target refuses to run while it is open — everything else in that file (site +permissions, history, the window rectangle) is left untouched. + Opening this repo in a JetBrains IDE is the last step: it offers to install every plugin in `.idea/externalDependencies.xml` in one click, and plugins are installed per IDE rather than per project, so that one prompt covers every project on the diff --git a/bin/chrome/prefs.sh b/bin/chrome/prefs.sh new file mode 100755 index 0000000..3e77cc0 --- /dev/null +++ b/bin/chrome/prefs.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Apply the Chrome settings that Google does not sync. +# +# Chrome syncs bookmarks, extensions, passwords and most of the Settings page, +# but a handful of window-chrome toggles are per-installation and never leave +# the machine — vertical tabs is the obvious one. Those are what this writes. +# +# A script and not a stow package: Chrome keeps them in `Default/Preferences`, +# one JSON blob that also holds site permissions, engagement scores, an upload +# seed and a window rectangle. Symlinking that into git would track the noise +# and leak the rest, so only the keys listed below are written and everything +# else is left as the machine has it. Same reasoning as bin/macos/defaults.sh. +# +# To add a setting: copy Preferences aside, flip the toggle in Chrome, quit it, +# then diff — Chrome only flushes the file on exit: +# cp "$CHROME_PREFS" /tmp/before # …flip it, quit Chrome… +# python3 -m json.tool "$CHROME_PREFS" | diff <(python3 -m json.tool /tmp/before) - +# and paste the dotted key here with its JSON value. +# +# usage: prefs.sh [--dry-run] +set -eu + +[ "${1:-}" != "--dry-run" ] || DRY_RUN=1 +: "${DRY_RUN:=}" + +# One `dotted.key ` per line. Split on the first space, so a string +# value has to be JSON without spaces ("en-US", not "en US") — none needs them. +prefs=' +vertical_tabs.enabled true +vertical_tabs.collapsed_state false +vertical_tabs.uncollapsed_width 240 +side_panel.is_right_aligned false +' + +# ponytail: the Default profile only. A second profile is a second $CHROME_PREFS +# run, and this repo has never had one. +case "$(uname -s)" in +Darwin) default_prefs="$HOME/Library/Application Support/Google/Chrome/Default/Preferences" ;; +*) default_prefs="$HOME/.config/google-chrome/Default/Preferences" ;; +esac +: "${CHROME_PREFS:=$default_prefs}" + +if [ -n "$DRY_RUN" ]; then + echo "$prefs" | grep -v '^$' | sed "s|^|set |" + echo "file $CHROME_PREFS" + exit 0 +fi + +[ -f "$CHROME_PREFS" ] || { echo "no Chrome profile at $CHROME_PREFS"; exit 1; } + +# Chrome holds the whole file in memory and rewrites it on exit, so a write made +# while it runs is silently discarded a few hours later — the worst kind of +# no-op. Refuse instead. +if pgrep -x "Google Chrome" >/dev/null 2>&1 || pgrep -x chrome >/dev/null 2>&1; then + echo "quit Chrome first - it overwrites $CHROME_PREFS on exit"; exit 1 +fi + +# python3 and not jq: it ships with macOS and with every runner, and jq is not +# in the Brewfile. Written to a temp file and renamed, so an interrupted run +# leaves the old Preferences intact rather than half a JSON document. +PREFS="$prefs" python3 - "$CHROME_PREFS" <<'PY' +import json, os, sys, tempfile + +path = sys.argv[1] +with open(path, encoding="utf-8") as f: + prefs = json.load(f) + +for line in os.environ["PREFS"].split("\n"): + if not line.strip(): + continue + key, value = line.split(" ", 1) + *parents, leaf = key.split(".") + node = prefs + for parent in parents: + node = node.setdefault(parent, {}) + node[leaf] = json.loads(value) + print("set %s = %s" % (key, value)) + +fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".") +with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(prefs, f, separators=(",", ":")) # compact, the way Chrome writes it +os.replace(tmp, path) +PY + +echo "applied - start Chrome" diff --git a/bin/chrome/prefs.test.sh b/bin/chrome/prefs.test.sh new file mode 100755 index 0000000..af88b4c --- /dev/null +++ b/bin/chrome/prefs.test.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Self-check for ./prefs.sh. Runs it against a throwaway Preferences file via +# $CHROME_PREFS, with a `pgrep` stub on PATH, so it never touches the real +# Chrome profile and gives the same answer on a runner with no Chrome at all. +set -eu + +script="$(cd "$(dirname "$0")" && pwd)/prefs.sh" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +fails=0 + +check() { # check + if [ "$2" = "$3" ]; then + echo "ok $1" + else + echo "FAIL $1"; echo " expected: $2"; echo " actual: $3"; fails=$((fails + 1)) + fi +} + +# A stand-in profile: one key this repo writes into, and one it must not touch. +# The nested "keep" is the point — Chrome's Preferences is one blob, and a patch +# that rewrites a whole subtree instead of a leaf loses site permissions. +printf '{"vertical_tabs":{"enabled":false,"enabled_first_time":true},"profile":{"keep":"me"}}' > "$tmp/Preferences" + +# pgrep says "nothing running", so the guard lets the write through. +printf '#!/bin/sh\nexit 1\n' > "$tmp/pgrep" +chmod +x "$tmp/pgrep" + +out=$(PATH="$tmp:$PATH" CHROME_PREFS="$tmp/Preferences" "$script") +read_key() { python3 -c 'import json,sys;d=json.load(open(sys.argv[1])) +for k in sys.argv[2].split("."): d=d[k] +print(json.dumps(d))' "$tmp/Preferences" "$1"; } + +check "the file is still valid JSON" '0' "$(python3 -m json.tool "$tmp/Preferences" >/dev/null 2>&1; echo $?)" +check "vertical tabs are on" 'true' "$(read_key vertical_tabs.enabled)" +check "the sibling key survives" 'true' "$(read_key vertical_tabs.enabled_first_time)" +check "an unrelated subtree survives" '"me"' "$(read_key profile.keep)" +check "a missing parent is created" 'false' "$(read_key side_panel.is_right_aligned)" +check "it reports what it wrote" '4' "$(grep -c '^set ' <<<"$out")" + +# Running it twice must not drift — this is what `make chrome` does on every +# machine, every time. +before=$(cat "$tmp/Preferences") +PATH="$tmp:$PATH" CHROME_PREFS="$tmp/Preferences" "$script" >/dev/null +check "idempotent" "$before" "$(cat "$tmp/Preferences")" + +# The guard: with Chrome running the write is discarded on its exit, so it must +# refuse rather than pretend. +printf '#!/bin/sh\nexit 0\n' > "$tmp/pgrep" +set +e +out=$(PATH="$tmp:$PATH" CHROME_PREFS="$tmp/Preferences" "$script" 2>&1); status=$? +set -e +check "refuses while Chrome runs" '1' "$status" +check "and says why" '1' "$(grep -c 'quit Chrome' <<<"$out")" + +# --dry-run must not need a profile, or reach one. +out=$(CHROME_PREFS="$tmp/absent" "$script" --dry-run) +check "dry run writes nothing" 'no' "$([ -f "$tmp/absent" ] && echo yes || echo no)" +check "dry run lists every key" '4' "$(grep -c '^set ' <<<"$out")" + +# Same shape the script parses: key, one space, JSON value. A pasted line with a +# space in the value would set the key to garbage without this. +keys=$(sed -n 's/^set \([^ ]*\) .*/\1/p' <<<"$out") +check "no key listed twice" '' "$(sort <<<"$keys" | uniq -d)" +check "every value is one JSON token" '4' \ + "$(grep -c '^set [a-z_.]* [^ ]*$' <<<"$out")" + +[ "$fails" -eq 0 ] || { echo "$fails failed"; exit 1; } +echo "all passed"