Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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*)",
Expand Down
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions bin/chrome/prefs.sh
Original file line number Diff line number Diff line change
@@ -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 <json value>` 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"
69 changes: 69 additions & 0 deletions bin/chrome/prefs.test.sh
Original file line number Diff line number Diff line change
@@ -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 <name> <expected> <actual>
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"
Loading