From bb253bcb86bdb779317a8788f3d6ee5384ac32ff Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:45:40 +0200 Subject: [PATCH 01/13] sketchybar: read die temperature from a helper, not macmon macmon's temp.cpu_temp_avg is not trustworthy. Measured inside a SINGLE invocation with CPU load flat at 1.5-3.7% and cpu_power at 0.06W: t+0s cpu=37.74 gpu=35.33 t+2s cpu=31.79 gpu=35.27 t+4s cpu=20.19 gpu=35.27 A die cannot cool 17C in two seconds and reheat. Across trials the value is bimodal, landing on ~31.9 or ~38.1 and never between, which is the signature of a mean over a varying sensor set rather than a temperature. helpers/thermal.swift reads the PMU tdie sensors directly through IOHIDEventSystemClient (no sudo). Spread 0.30C over 20 reads, and it tracks real load: 34.9 idle -> 37.6 under full load, monotonic. The helper was untracked until now, while both install and system.sh depended on it. Both guarded defensively, so a fresh clone produced a bar with no temperature and no error at all. --- docs/audit-2026-08-02.md | 990 ++++++++++++++++++ .../.config/sketchybar/helpers/thermal.swift | 109 ++ .../.config/sketchybar/plugins/system.sh | 92 +- 3 files changed, 1177 insertions(+), 14 deletions(-) create mode 100644 docs/audit-2026-08-02.md create mode 100644 sketchybar/.config/sketchybar/helpers/thermal.swift diff --git a/docs/audit-2026-08-02.md b/docs/audit-2026-08-02.md new file mode 100644 index 0000000..94354ef --- /dev/null +++ b/docs/audit-2026-08-02.md @@ -0,0 +1,990 @@ +# Production Readiness Audit — AeroSpace / SketchyBar Desktop Environment + +**Repository:** `dl/dotfiles` +**Audit date:** 2026-08-02 +**Scope:** AeroSpace, SketchyBar, all shell plugins, repository architecture, portability +**Method:** static review of all 21 SketchyBar files and `aerospace.toml`, plus runtime +measurement on the live host (single 2560×1440 external, Apple Silicon, macOS 26, Danish layout) + +> **Evidence convention.** Every performance number in this report was measured on the audited +> host and the command is given. Claims that were *not* verified on hardware are labelled +> **[INFERRED]**. Anything labelled **[SELF-INFLICTED]** was introduced by recent work in this +> repository and is reported without softening. + +--- + +## 1. Executive Summary + +This is an unusually well-documented personal desktop configuration with a genuinely high standard +of recorded reasoning — measurements are captured in comments, trade-offs are argued rather than +asserted, and several non-obvious platform behaviours (SketchyBar's group geometry, CoreText ink +metrics, TCC boundaries) are documented at a level most production codebases never reach. + +It is also **not production ready as an open-source artefact**, for reasons that are mostly +structural rather than functional. The configuration works well day-to-day. What it lacks is the +ability to survive contact with a second machine, a second maintainer, or six months of neglect. + +The three findings that matter most: + +1. **The bar consumes 8.6% of one CPU core, continuously, to draw a status bar.** Measured: + 5,181 ms of CPU per minute. Three plugins account for 86% of it, and the worst offender polls + a 63 ms helper every 2 seconds to answer a question that has an event-driven API. +2. **The entire working tree is uncommitted**, including a file (`helpers/thermal.swift`) that two + other components depend on and that is not tracked by git at all. A fresh clone silently + degrades rather than failing. +3. **The configuration has a systemic silent-failure culture.** Four distinct classes of + "fails quietly and looks identical to working" were found. Three were fixed recently; the + pattern that produced them is not yet encoded anywhere enforceable. + +The dominant maintainability risk is not complexity — it is that **the documentation is load-bearing +and is already drifting from reality**. Three comments were found asserting things that are +measurably false. In a config where comments carry the measurements, a false comment is a defect. + +--- + +## 2. Scores + +| Dimension | Score | Justification | +|---|---:|---| +| **Production readiness** | **6 / 10** | Functions reliably in daily use; blocked from higher by uncommitted state, untracked dependency, and no test or CI of any kind. | +| **Architecture** | **5 / 10** | 951-line monolithic `sketchybarrc`; zero shared code across 17 plugins; `hide()` redefined 3×, truncation idiom copy-pasted 3×, a 6-line `osascript` block duplicated verbatim within one file. | +| **Performance** | **4 / 10** | 8.6% of one core at idle, measured. `mic` alone is 1,890 ms/min. Three plugins poll where an event exists. No caching of invariant lookups. | +| **Maintainability** | **7 / 10** | Exceptional comment quality and rationale capture — the single strongest attribute. Penalised for monolith size and for three provably-false comments. | +| **Reliability** | **6 / 10** | Four silent-failure classes identified; three fixed. Failure modes are consistently *quiet*, which is the worst property a status bar can have. | +| **Security** | **7 / 10** | No secrets in repo; `.gitignore` defensively excludes runtime state; `--no-folding` deliberately prevents app state landing in the repo. Penalised for unauthenticated IP-geolocating network call and a vendored credential-reading script. | +| **Portability** | **4 / 10** | Hardcoded `/opt/homebrew` (breaks on Intel); hard dependency on Danish layout, Apple Silicon, single screen, and eight third-party binaries with no capability detection. | + +**Weighted overall: 5.6 / 10** — a strong personal configuration, a weak open-source project. + +--- + +## 3. Critical Findings + +### GIT-001 — Untracked file is a hard dependency of two components + +| | | +|---|---| +| **Category** | Reliability / Build integrity | +| **Severity** | **Critical** | +| **Files** | `sketchybar/.config/sketchybar/helpers/thermal.swift` (untracked), `install`, `sketchybar/.config/sketchybar/plugins/system.sh` | +| **Effort** | 5 min | +| **Depends on** | Nothing | + +**Description.** `helpers/thermal.swift` is not tracked by git. `install` builds it and `system.sh` +both builds and executes it. + +**Root cause.** The file was created and never `git add`ed. Both consumers guard defensively — +`install` uses `[[ -r $thermal_src ]]` and `system.sh` checks `[ -r "$SRC" ]` — so both **skip +silently** when it is absent. + +**Impact.** A fresh clone produces a bar with no temperature reading and **no error**. The +`install` script prints no warning because its guard fails before the `warn` branch is reachable. +This is the exact silent-degradation pattern the rest of this report criticises, in the build path. + +**Recommended implementation.** +```bash +git add sketchybar/.config/sketchybar/helpers/thermal.swift +``` +Then harden the guard so absence is loud rather than silent: +```bash +# install — distinguish "no swiftc" from "source missing" +thermal_src="${HOME}/.config/sketchybar/helpers/thermal.swift" +if [[ ! -r $thermal_src ]]; then + warn "thermal helper source missing at $thermal_src — temperature will be omitted." +elif ! command -v swiftc &>/dev/null; then + warn "swiftc not found — thermal helper not built; temperature will be omitted." +elif swiftc -O -o "${thermal_src:h}/thermal" "$thermal_src" 2>/dev/null; then + ok "sketchybar thermal helper built." +else + warn "sketchybar thermal helper failed to build; temperature will be omitted." +fi +``` + +**Edge cases.** The same three-way guard applies to `mic.swift`; fix both for symmetry. + +**Risks.** None. + +**Acceptance criteria.** +1. `git ls-files sketchybar/.config/sketchybar/helpers/` lists both `.swift` files. +2. `git stash -u && ./install` emits an explicit warning naming the missing file. +3. Fresh clone → `./install` → `sketchybar --query thermals` shows a temperature. + +--- + +### GIT-002 — Entire working tree uncommitted + +| | | +|---|---| +| **Category** | Process | +| **Severity** | **Critical** | +| **Files** | 10 modified, 2 untracked | +| **Effort** | 15 min | +| **Depends on** | GIT-001 | + +**Description.** `git status` shows 10 modified files and 2 untracked. Last commit is 23 hours old. + +**Impact.** Substantial recent work — the `updates=on` reliability sweep across 9 items, the +thermal helper, the Danish keyboard remediation, the single-screen strip — exists only in the +working tree. Any `git checkout`, failed merge, or disk event destroys it. There is no upstream +divergence (`0` commits ahead), so nothing is backed up remotely either. + +**Recommended implementation.** Commit in coherent units rather than one blob, so the history stays +bisectable: + +```bash +git add sketchybar/.config/sketchybar/helpers/thermal.swift \ + sketchybar/.config/sketchybar/plugins/system.sh +git commit -m "sketchybar: read die temperature from a helper, not macmon + +macmon's temp.cpu_temp_avg is bimodal at flat idle — measured landing on +either ~31.9 or ~38.1 and dropping to 20C while cpu_power sat at 0.06W. +It is a mean over a varying sensor set. thermal.swift reads PMU tdie* +directly via IOHIDEventSystemClient; spread 0.30C over 20 reads." + +git add sketchybar/.config/sketchybar/sketchybarrc +git commit -m "sketchybar: updates=on for every self-hiding item + +A hidden item under the config-wide when_shown default stops being +updated entirely, so it can never un-hide. Eight items were affected." + +# ... aerospace, docs, etc. +``` + +**Acceptance criteria.** `git status --short` is empty; `git log --oneline -8` shows discrete, +self-describing commits. + +--- + +## 4. High Findings + +### PERF-001 — `mic` polling costs 1,890 ms/min; the helper is 8× slower than documented + +| | | +|---|---| +| **Category** | Performance | +| **Severity** | **High** | +| **Files** | `sketchybar/.config/sketchybar/helpers/mic.swift:17`, `sketchybarrc:745`, `plugins/mic.sh` | +| **Effort** | 20 min (interval change) / 3 h (event-driven rewrite) | +| **Depends on** | Nothing | + +**Description.** The `mic` item runs a compiled helper every 2 seconds. Measured across 10 +consecutive runs: **min 60 ms, median 63 ms, max 65 ms**. At `update_freq=2` that is +**1,890 ms of CPU per minute** — 36% of the bar's entire budget and ~3% of one core, continuously. + +**Root cause.** Two compounding errors. + +1. `mic.swift:17` asserts *"Compiled it runs in single-digit ms."* This is **false by a factor of + 8**. The claim likely measured process startup delta rather than end-to-end wall time. The 2 s + interval was chosen *because* of that false premise — the comment reads "The 2s poll is + affordable because the helper is a compiled binary". +2. The cost is inherent to the approach: the helper enumerates **all** CoreAudio devices, queries + `kAudioDevicePropertyStreamConfiguration` per device to identify inputs, then queries + `kAudioDevicePropertyDeviceIsRunningSomewhere` per input. That is O(devices) IPC round-trips + into `coreaudiod` on every tick. + +**Reproduce:** +```bash +python3 -c " +import subprocess, time +ts=[] +for _ in range(10): + s=time.time(); subprocess.run(['$HOME/.config/sketchybar/helpers/mic'],capture_output=True) + ts.append((time.time()-s)*1000) +print(f'min={min(ts):.0f} median={sorted(ts)[5]:.0f} max={max(ts):.0f}')" +``` + +**Recommended implementation — two tiers.** + +*Tier 1 (20 min, recommended first).* Correct the comment and raise the interval. A recording +indicator does not need 2-second latency: +``` +update_freq=5 # 1890 -> 756 ms/min, a 60% reduction +``` +And in `mic.swift`, replace the false claim: +```swift +// WHY IT IS COMPILED: `/usr/bin/swift` interpreting even a trivial script measured +// 1.25s, which is not pollable. Compiled it runs in ~63ms (median of 10 runs) — +// NOT single-digit ms, as this comment previously claimed. The cost is inherent: +// enumerating CoreAudio devices and querying two properties per device is O(devices) +// IPC into coreaudiod. That 63ms is why update_freq is 5 and not 2; at 2s it was +// 1890ms/min, the single most expensive thing in this config. +``` + +*Tier 2 (3 h, the correct fix).* Make it event-driven. CoreAudio supports property listeners on +`kAudioDevicePropertyDeviceIsRunningSomewhere`. Convert `mic` from a polled helper to a resident +listener that pushes a custom SketchyBar event: +```swift +// Resident daemon; on change: system("sketchybar --trigger mic_changed") +AudioObjectAddPropertyListener(deviceID, &addr, handler, nil) +``` +`sketchybarrc` then becomes: +``` +--add event mic_changed +--add item mic right +--subscribe mic mic_changed +--set mic updates=on update_freq=0 script="$PLUGIN_DIR/mic.sh" +``` +This drops the cost to **0 ms/min** at idle. Precedent exists: `volume` and `wifi` already ride +native events and never poll. + +**Edge cases.** A resident daemon needs a lifecycle owner. Start it from `aerospace.toml`'s +`after-startup-command` alongside `borders` and `sketchybar` so its lifetime matches theirs — do +**not** use `brew services`, per the existing documented rationale. + +**Risks.** Tier 2 introduces a long-lived process; a crash silently stops updates. Mitigate by +having `mic.sh` fall back to a one-shot read if the daemon is absent. + +**Acceptance criteria.** +1. Comment states the measured figure. +2. Tier 1: `update_freq=5`; recompute → ≤ 760 ms/min. +3. Tier 2: `mic` shows `update_freq=0`; starting a recording lights the item within 1 s. + +--- + +### PERF-002 — `system` plugin costs 1,860 ms/min, dominated by a 854 ms `macmon` call + +| | | +|---|---| +| **Category** | Performance | +| **Severity** | **High** | +| **Files** | `plugins/system.sh:26`, `sketchybarrc` (thermals item) | +| **Effort** | 45 min | +| **Depends on** | Nothing | + +**Description.** `system.sh` runs `macmon pipe -s 1 -i 100` every 30 s. Measured: **854 ms**. +Plus the thermal helper at 76 ms → **930 ms per invocation, 1,860 ms/min** (36% of budget). + +**Root cause.** `macmon` samples a full SoC telemetry frame — power, per-cluster frequencies, ANE, +GPU — to extract three values: CPU %, RAM used, fan RPM. The `-i 100` interval sets a 100 ms +sampling window, but the observed 854 ms is dominated by process startup and IOReport subscription +setup, not the window. + +**Recommended implementation.** Replace `macmon` with direct reads. All three values are available +far more cheaply: + +- **RAM** — `host_statistics64` VM stats. Instantaneous, no sampling window. +- **CPU %** — `host_processor_info` deltas. Needs two samples; the existing `thermal.swift` helper + can hold state between invocations if converted to a short-lived daemon, or sample over ~100 ms. +- **Fan RPM** — the blocker. **[INFERRED]** — I scanned Apple's HID sensor usage page and found + only usage 5 (temperature, 39 services) and usage 1; no fan service was exposed. Fan RPM likely + requires SMC access. **Verify before committing to this path:** if fan RPM is not reachable + without `macmon`, keep `macmon` solely for that and drop its interval to 60 s. + +Interim change (5 min, no investigation needed): +``` +update_freq=60 # 1860 -> 930 ms/min +``` +CPU/RAM/fan at 30 s resolution is not information anyone acts on faster than 60 s. + +**Edge cases.** `system.sh` writes three items from one sample by design (documented). Any +replacement must preserve that — splitting into three polled items would triple the cost. + +**Acceptance criteria.** `time bash plugins/system.sh` ≤ 200 ms, **or** `update_freq=60` with the +one-sample-three-items property intact and `cpu`/`memory`/`thermals` all still populating. + +--- + +### PERF-003 — `aerospace.sh` spawns 9 subprocesses per event, twice per event class + +| | | +|---|---| +| **Category** | Performance / Architecture | +| **Severity** | **High** | +| **Files** | `plugins/aerospace.sh:19`, `sketchybarrc:262` | +| **Effort** | 40 min | +| **Depends on** | Nothing | + +**Description.** Each of 9 workspace items runs `aerospace.sh`, and each invocation shells out to +`aerospace list-workspaces --monitor all --empty no`. Measured: **25 ms each, 104 ms for 9 +sequential calls**. + +Worse, `sketchybarrc:262` subscribes every workspace item to **both** +`aerospace_workspace_change` **and** `front_app_switched`: +``` +--subscribe "space.$sid" aerospace_workspace_change front_app_switched +``` +So every application switch — a very high-frequency user action — triggers 9 `aerospace` CLI +invocations plus 9 `grep` subprocesses. + +**Root cause.** Each item independently re-derives global state (which workspaces are occupied) +that is identical across all nine. + +**Recommended implementation.** Compute once, distribute to all items in a single `sketchybar` +call. Replace per-item `aerospace.sh` with one script driven by a single item: + +```bash +#!/usr/bin/env bash +# workspaces.sh — repaints ALL workspace pills from ONE query. +source "$HOME/.config/sketchybar/colors.sh" + +focused="${FOCUSED_WORKSPACE:-$(aerospace list-workspaces --focused 2>/dev/null)}" +occupied=" $(aerospace list-workspaces --empty no 2>/dev/null | tr '\n' ' ')" + +args=() +for sid in $(aerospace list-workspaces --all 2>/dev/null); do + if [ "$sid" = "$focused" ]; then + args+=(--set "space.$sid" background.drawing=on label.color="$CRUST" icon.color="$CRUST") + elif [ "${occupied#* $sid }" != "$occupied" ]; then + args+=(--set "space.$sid" background.drawing=off label.color="$TEXT" icon.color="$TEXT") + else + args+=(--set "space.$sid" background.drawing=off label.color="$SURFACE2" icon.color="$SURFACE2") + fi +done +sketchybar "${args[@]}" +``` + +This is **2 `aerospace` calls and 1 `sketchybar` call total** instead of 9 + 9 + 9. It also removes +the `grep -qx` subprocess per item via parameter expansion. + +Also drop `--monitor all` (meaningless on one screen) and reconsider the `front_app_switched` +subscription — it exists so a newly-opened window marks its workspace occupied, but `front_app_switched` +is the wrong signal for that; window creation is already covered by `aerospace_workspace_change` +when the window lands. **[INFERRED]** — verify by opening a window in an empty workspace and +confirming the pill lights without the subscription. + +**Edge cases.** The `${occupied#* $sid }` test requires the sentinel spaces added above; without +them workspace `1` would match inside `10`. This config has single-digit workspaces only, but the +guard costs nothing and prevents a real bug if workspaces ever reach double digits. + +**Acceptance criteria.** +1. One `aerospace_workspace_change` produces ≤ 3 subprocesses (verify with `fs_usage` or by timing). +2. Switching workspaces still repaints all 9 pills correctly, including the mauve focused pill. +3. Total repaint latency < 40 ms. + +--- + +### DOC-001 — Three comments assert things that are measurably false + +| | | +|---|---| +| **Category** | Maintainability | +| **Severity** | **High** | +| **Files** | `plugins/system.sh:76` **[SELF-INFLICTED]**, `sketchybarrc:712`, `aerospace.toml:221` | +| **Effort** | 10 min | +| **Depends on** | Nothing | + +**Description.** In a configuration whose comments carry the measurements, a false comment is a +defect of the same class as a false assertion in code. Three were found: + +| Location | Claim | Reality | +|---|---|---| +| `system.sh:76` | *"see the WIDTH BUDGET in sketchybarrc"* | `grep -c 'WIDTH BUDGET' sketchybarrc` → **0**. Deleted during the single-screen strip. **[SELF-INFLICTED]** | +| `sketchybarrc:712` | *"see the privacy note in weather.sh"* | `grep -ci privacy weather.sh` → **0**. The note never existed. | +| `aerospace.toml:221` | *"Raycast is in the Brewfile but not installed here"* | `/Applications/Raycast.app` **is installed**; bundle id `com.raycast.macos` — exactly the value the comment declined to guess. | + +**Impact.** Each sends a future maintainer to a section that does not exist, or encodes a false +premise. The third actively blocks a working feature: the Raycast floating rule is commented out +awaiting a bundle ID that is now trivially obtainable. + +**Recommended implementation.** + +`system.sh:76` — the width justification no longer applies on a single screen: +```bash +# The helper prints one decimal — precise enough to watch the die move while +# debugging, but the bar shows a whole number. A tenth of a degree is not +# information anyone acts on, and it keeps the pill one character narrower. +``` + +`sketchybarrc:712` — either write the note or drop the reference. Write it (see SEC-001): +```bash +# Current conditions. 900s because weather does not change faster than that and +# every poll is an unauthenticated request to a third party that geolocates by +# source IP — see the privacy note in weather.sh. +``` + +`aerospace.toml:221` — verify and enable: +```toml +{ if = 'test %{app-bundle-id} = com.raycast.macos', run = 'layout floating' }, +``` +Confirm first with Raycast Settings open: `aerospace list-apps | grep -i raycast`. + +**Acceptance criteria.** Every `see ` reference in both configs resolves: +```bash +grep -oE 'see [a-z_]+\.(sh|swift|toml)|see the [A-Z][A-Z ]+' sketchybarrc aerospace.toml +# every target must exist +``` + +--- + +### SB-001 — Silent-failure patterns are documented but not enforced + +| | | +|---|---| +| **Category** | Reliability | +| **Severity** | **High** | +| **Files** | `CLAUDE.md`, all plugins, `install` | +| **Effort** | 1.5 h | +| **Depends on** | Nothing | + +**Description.** Four classes of failure that are invisible when they occur were identified. Three +have been fixed reactively; nothing prevents recurrence. + +| Class | Mechanism | Status | +|---|---|---| +| Self-hiding item cannot recover | `updates_only_when_shown ? is_shown : true` gates timed **and** event runs; `bar_draw` clears bar association of undrawn items. Item works after reload, hides, never runs again. | Fixed on 9 items | +| Missing execute bit | SketchyBar `fork_exec`s plugins and reports nothing when non-executable. Item never updates — identical to a script that runs and does nothing. | Fixed once | +| Plugin TCC ≠ terminal TCC | The bar has no Full Disk Access; a terminal usually does. `~/Library` reads succeed by hand and fail in-plugin with `EPERM` **and correct Unix permissions**. | Documented | +| Silent property rejection | `label.max_chars`, `blur_radius`, `notch_*` are **not** echoed by `--query`. A wrong value looks identical to a right one. | Documented | + +**Recommended implementation.** Add a repository self-check, runnable and CI-able: + +```bash +#!/usr/bin/env bash +# scripts/lint-sketchybar.sh — catches the four silent-failure classes. +set -u +cd "$(dirname "$0")/.." || exit 1 +P=sketchybar/.config/sketchybar/plugins +RC=sketchybar/.config/sketchybar/sketchybarrc +fail=0 + +# 1. every plugin executable +for f in "$P"/*.sh; do + [ -x "$f" ] || { echo "FAIL: $f is not executable (sketchybar will never run it)"; fail=1; } +done + +# 2. every self-hiding item declares updates=on +for f in "$P"/*.sh; do + grep -q 'set "$NAME" drawing=off\|set "$NAME" .*drawing=off' "$f" || continue + item=$(basename "$f" .sh) + awk -v it="$item" ' + $0 ~ "--add item "it" " {found=1} + found && /updates=on/ {ok=1} + found && /^sketchybar|^# ---/ && !/--add item/ {found=0} + END {exit ok?0:1}' "$RC" \ + || { echo "FAIL: item '$item' self-hides but lacks updates=on"; fail=1; } +done + +# 3. shellcheck if available +command -v shellcheck >/dev/null && shellcheck -S warning "$P"/*.sh || true + +exit $fail +``` + +Wire into `.github/workflows/` (the repo already has a `.github/` directory) and document in +`CLAUDE.md` as a pre-commit step. + +**Edge cases.** The awk item-block detection is heuristic. Accept false negatives; a linter that +catches most cases beats none. Do not make it block on ambiguity. + +**Acceptance criteria.** +1. `scripts/lint-sketchybar.sh` exits 0 on the current tree. +2. `chmod -x` any plugin → exits 1 naming it. +3. Remove `updates=on` from `mic` → exits 1 naming it. + +--- + +## 5. Medium Findings + +### ARCH-001 — No shared plugin library; six duplication sites + +| | | +|---|---| +| **Category** | Architecture | +| **Severity** | Medium | +| **Files** | all 17 plugins | +| **Effort** | 2 h | +| **Depends on** | Nothing (but touches every plugin — schedule to avoid conflicts) | + +**Measured duplication:** + +| Pattern | Count | Files | +|---|---:|---| +| `source .../colors.sh` | 15 / 17 | all but `clock.sh`, `front_app.sh` | +| `hide()` redefined | 3 | `calendar.sh`, `music.sh`, `mic.sh` | +| `cut -c1-N` + ellipsis truncation | 3 | `bluetooth.sh`, `calendar.sh`, `vpn.sh` | +| `${TMPDIR:-/tmp}/sketchybar-*` state | 3 | `github.sh`, `pomodoro.sh`, `weather.sh` | +| Verbatim `osascript` block | 2× in 1 file | `amphetamine.sh:17-22` ≡ `:34-39` | +| Helper build-on-demand block | 2 | `mic.sh`, `system.sh` | + +**Recommended implementation.** Add `sketchybar/.config/sketchybar/lib.sh`: + +```bash +#!/usr/bin/env bash +# Shared plugin helpers. Source AFTER colors.sh. +# Every function assumes $NAME is set by sketchybar. + +# Hide this item and exit. The single most repeated idiom in the plugins. +hide() { sketchybar --set "$NAME" drawing=off "${@}"; exit 0; } + +# Truncate to $2 chars with an ellipsis. Pure parameter expansion — no subprocess. +truncate_label() { + local s=$1 n=$2 + if [ "${#s}" -gt "$n" ]; then printf '%s…' "${s:0:$n}"; else printf '%s' "$s"; fi +} + +# Path for a plugin's cache/state file. +state_file() { printf '%s/sketchybar-%s' "${TMPDIR:-/tmp}" "$1"; } + +# Build a compiled helper on demand; echo its path, or return 1. +ensure_helper() { + local name=$1 dir="$HOME/.config/sketchybar/helpers" + local src="$dir/$name.swift" bin="$dir/$name" + if [ ! -x "$bin" ] || [ "$src" -nt "$bin" ]; then + [ -r "$src" ] && command -v swiftc >/dev/null 2>&1 || return 1 + swiftc -O -o "$bin.new" "$src" >/dev/null 2>&1 \ + && mv "$bin.new" "$bin" || { rm -f "$bin.new"; return 1; } + fi + printf '%s' "$bin" +} +``` + +Note `truncate_label` replaces a `printf | cut` **pipeline of two subprocesses** with pure +parameter expansion — applied 3× on every relevant plugin invocation. + +**Risks.** Touching all 17 plugins at once maximises merge conflict surface. Do this as its own +commit, after PERF-001/002/003 have landed. + +**Acceptance criteria.** `grep -c '^hide()' plugins/*.sh` → 0; `grep -lc 'cut -c1-' plugins/*.sh` +→ empty; every plugin still behaves identically (spot-check each item after reload). + +--- + +### ARCH-002 — `sketchybarrc` is a 951-line monolith + +| | | +|---|---| +| **Category** | Architecture | +| **Severity** | Medium | +| **Files** | `sketchybarrc` | +| **Effort** | 2 h | +| **Depends on** | ARCH-001 | + +**Description.** 951 lines in one file, overwhelmingly comment. The comments are the repository's +best asset, so the fix is **not** to delete them — it is to give them a smaller scope to describe. + +**Recommended structure:** +``` +sketchybar/.config/sketchybar/ +├── sketchybarrc # ~120 lines: bar setup, defaults, sources the rest, --update +├── lib.sh # shared plugin helpers (ARCH-001) +├── colors.sh # palette (unchanged) +├── items/ +│ ├── workspaces.sh # island.spaces +│ ├── app.sh # island.app +│ ├── media.sh # island.media + popup +│ ├── system.sh # island.system +│ ├── status.sh # island.status +│ ├── network.sh # island.network +│ ├── hardware.sh # island.hardware +│ └── time.sh # island.time +├── plugins/ # unchanged +└── helpers/ # unchanged +``` + +`sketchybarrc` becomes: +```bash +source "$CONFIG_DIR/colors.sh" +for f in "$CONFIG_DIR"/items/*.sh; do source "$f"; done +sketchybar --update +``` + +**Critical ordering constraint.** SketchyBar lays items out in **add order** — first-added is +leftmost on the left side and **rightmost** on the right side. Sourcing `items/*.sh` alphabetically +would silently reorder the entire bar. Source them **explicitly in the current order**, and put a +comment saying why the glob is not used: +```bash +# EXPLICIT ORDER, NOT A GLOB. Add order IS the layout — see the note in items/time.sh. +for f in workspaces app media time hardware network status system; do + source "$CONFIG_DIR/items/$f.sh" +done +``` + +**Risks.** High conflict surface; the bracket definitions at the bottom reference items defined +throughout. Brackets must stay in a single file sourced last, or each `items/*.sh` must define its +own bracket (preferred — it colocates the island with its members). + +**Acceptance criteria.** Byte-identical bar geometry before/after: +```bash +for b in island.spaces island.app island.media island.system island.status \ + island.network island.hardware island.time; do + sketchybar --query "$b" | jq -c '.bounding_rects."display-1"' +done # diff before vs after must be empty +``` + +--- + +### SEC-001 — Unauthenticated IP-geolocating request every 15 minutes + +| | | +|---|---| +| **Category** | Security / Privacy | +| **Severity** | Medium | +| **Files** | `plugins/weather.sh:6,9` | +| **Effort** | 15 min | +| **Depends on** | Nothing | + +**Description.** `LOCATION=""` produces `https://wttr.in/?format=%C|%t`. With an empty location, +wttr.in **geolocates by source IP**. The bar therefore discloses the host's public IP to a +third-party service every 900 s, and receives location-correlated data back. + +**Impact.** Low for a personal machine on a home connection; meaningful if this repository is +open-sourced and copied, because the behaviour is not obvious from reading the config — and +`sketchybarrc:712` tells the reader a privacy note exists that does not. + +**Recommended implementation.** Document it and make it opt-out: +```bash +#!/usr/bin/env bash +# Current conditions from wttr.in. +# +# PRIVACY: with LOCATION empty, wttr.in geolocates by SOURCE IP — every poll +# discloses this host's public IP to a third party and returns location-correlated +# data. Set LOCATION to a city name to send an explicit location instead (still a +# third-party request, but no IP-based inference), or set it to "off" to disable +# the item entirely. +LOCATION="${SKETCHYBAR_WEATHER_LOCATION:-}" + +[ "$LOCATION" = "off" ] && { sketchybar --set "$NAME" drawing=off; exit 0; } +``` +Also add `--fail` to the curl invocation so HTTP errors do not populate the cache with an error page: +```bash +reading="$(curl -sf --max-time 5 "https://wttr.in/${LOCATION}?format=%C|%t" 2>/dev/null)" +``` + +**Acceptance criteria.** `weather.sh` contains the word "privacy"; `SKETCHYBAR_WEATHER_LOCATION=off` +hides the item; a 500 from wttr.in does not overwrite the cache. + +--- + +### PORT-001 — Hardcoded `/opt/homebrew` breaks on Intel Macs + +| | | +|---|---| +| **Category** | Portability | +| **Severity** | Medium | +| **Files** | `sketchybarrc:61`, `raycast/.config/raycast/scripts/reload-sketchybar.sh:23` | +| **Effort** | 15 min | +| **Depends on** | Nothing | + +**Description.** Both files hardcode the Apple Silicon Homebrew prefix. Intel Macs use +`/usr/local`. On Intel, `sketchybarrc` would fail to find `aerospace`, `macmon`, `gh` and +`icalBuddy`, and every dependent plugin would silently degrade. + +**Recommended implementation.** Prepend both prefixes — harmless when one does not exist: +```bash +# Both Homebrew prefixes: /opt/homebrew (Apple Silicon), /usr/local (Intel). +# Prepending a nonexistent directory is harmless, and this avoids a `brew --prefix` +# subprocess on every bar start. +export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:$PATH" +``` + +**Acceptance criteria.** `grep -c '/opt/homebrew' --include='*.sh' -r .` shows every hit paired +with a `/usr/local` fallback. + +--- + +### SB-002 — No capability detection for eight third-party binaries + +| | | +|---|---| +| **Category** | Reliability / Portability | +| **Severity** | Medium | +| **Files** | `plugins/*.sh`, `Brewfile` | +| **Effort** | 45 min | +| **Depends on** | ARCH-001 | + +**Description.** Plugins depend on `aerospace`, `macmon`, `gh`, `icalBuddy`, `curl`, `osascript`, +`system_profiler`, `pmset`. Only `calendar.sh` guards (`command -v icalBuddy || hide`). The rest +fail into empty output, and most then hide — indistinguishable from "nothing to report". + +**Impact.** On a fresh machine where `macmon` is absent, the system island vanishes with no +indication that a dependency is missing rather than idle. + +**Recommended implementation.** Standardise via `lib.sh`: +```bash +# Require a command or hide with a one-time warning to stderr (visible in sketchybar's log). +require() { + command -v "$1" >/dev/null 2>&1 && return 0 + printf 'sketchybar/%s: missing dependency: %s\n' "${NAME:-?}" "$1" >&2 + hide +} +``` +Apply as `require macmon` / `require gh` / `require aerospace` at the top of each plugin. + +**Acceptance criteria.** Temporarily rename `macmon`; the island hides **and** the reason appears +on stderr. + +--- + +### SH-001 — Unnecessary subprocesses in hot paths + +| | | +|---|---| +| **Category** | Shell / Performance | +| **Severity** | Medium | +| **Files** | `battery.sh:8-9`, `calendar.sh:28-31`, `wifi.sh:7-11`, `aerospace.sh:19` | +| **Effort** | 45 min | +| **Depends on** | ARCH-001 | + +**Findings:** + +**`battery.sh:8-9`** — 5 subprocesses to parse one `pmset` output: +```bash +pct="$(printf '%s' "$batt" | grep -Eo '[0-9]+%' | head -1 | tr -d '%')" +charging="$(printf '%s' "$batt" | grep -c "AC Power")" +``` +Replace with parameter expansion: +```bash +pct="${batt#* }"; pct="${pct%%\%*}"; pct="${pct##* }" +case "$batt" in *"AC Power"*) charging=1 ;; *) charging=0 ;; esac +``` +Also note a semantic bug: `charging` is true whenever on AC, but a **full battery on AC is not +charging**. The green colour therefore claims "charging" while merely plugged in. Use +`pmset`'s own `; charging` / `; charged` suffix. + +**`calendar.sh:28-31`** — 4 subprocesses (`printf | tr | sed | sed`) for title cleanup. Collapse +to a single `sed` with multiple `-e`, or better, have `icalBuddy` emit a cleaner format. + +**`wifi.sh:7-11`** — `networksetup -listallhardwareports` (**40 ms measured**) plus `awk` plus +`ifconfig` plus `grep`, on every invocation, to discover a device name **that never changes**. +Cache it: +```bash +DEV_CACHE="$(state_file wifi-dev)" +if [ -s "$DEV_CACHE" ]; then read -r dev < "$DEV_CACHE"; else + dev="$(networksetup -listallhardwareports 2>/dev/null \ + | awk '/Hardware Port: Wi-Fi/{getline; print $2; exit}')" + dev="${dev:-en0}"; printf '%s' "$dev" > "$DEV_CACHE" +fi +``` + +**Acceptance criteria.** `battery.sh` and `wifi.sh` each spawn ≤ 2 subprocesses; all items render +identically. + +--- + +### SB-003 — `music.sh` polls Apple Music via AppleScript every 10 s + +| | | +|---|---| +| **Category** | Performance | +| **Severity** | Medium | +| **Files** | `plugins/music.sh`, `sketchybarrc` (music item) | +| **Effort** | 30 min | +| **Depends on** | Nothing | + +**Description.** ~120 ms per invocation at `update_freq=10` → **720 ms/min** (14% of budget), +paid whether or not Music is running. The `pgrep -x Music` guard short-circuits when Music is +closed, which is the common case — but when it *is* open, this is continuous AppleScript IPC. + +**Critical note:** the `pgrep` guard is **load-bearing, not defensive**. Verified during this +audit: `osascript -e 'tell application "Music" ...'` **launches Music** when it is not running. +Removing the guard would cause the status bar to start a media player. Do not remove it. + +**Recommended implementation.** SketchyBar exposes a `media_change` event (`MEDIA_CHANGED` in +`src/event.h`) driven by `MPNowPlayingInfoCenter` — which covers **any** player, not just Apple +Music, and fires on change rather than on a timer: +``` +--add item music left +--subscribe music media_change mouse.clicked +--set music updates=on update_freq=0 ... +``` +`$INFO` carries the now-playing payload as JSON. **[INFERRED]** — the event exists in the binary; +the payload shape was not verified during this audit. Confirm with: +```bash +sketchybar --add item probe.m left --subscribe probe.m media_change \ + --set probe.m script='echo "$INFO" >> /tmp/media.log' +``` + +**Acceptance criteria.** `music` shows `update_freq=0`; starting playback updates the pill within +1 s; the `pgrep` guard is retained or provably unnecessary. + +--- + +## 6. Low Findings + +### LOW-001 — Four unused colour constants +`colors.sh` exports `BASE`, `MANTLE`, `SURFACE0`, `SURFACE1` — zero references in `sketchybarrc` +or any plugin. **Keep them.** `colors.sh` is a *palette*, and a complete Catppuccin Frappé palette +is more useful than a minimal one. Add a one-line header saying so, to stop a future reader +"tidying" them away. **Effort: 2 min.** + +### LOW-002 — `clock.sh` polls on a 15 s cycle unaligned to the minute +`update_freq=15` means the displayed minute can be up to 15 s stale, and 3 of every 4 invocations +are wasted. Aligning to the minute boundary is possible but adds complexity for a cosmetic gain. +**Recommendation: accept, and document the trade-off.** **Effort: 5 min (comment only).** + +### LOW-003 — `pomodoro.sh` notification uses unescaped interpolation +```bash +notify() { osascript -e "display notification \"$2\" with title \"$1\""; } +``` +Both arguments are string literals at every call site, so there is no injection path today. It is +a fragile pattern that would break on an apostrophe if the messages ever became dynamic. Use +`osascript -e '...' -- "$1" "$2"` with `on run argv`. **Effort: 10 min.** + +### LOW-004 — `pomodoro.sh` state file has a benign read/write race +The state file is read at the top and written by `start()`/`idle()`. Two invocations overlapping +(a click landing during a 1 s tick) could interleave. Consequence is at worst one wrong frame, +self-correcting on the next tick. **Recommendation: accept; document.** **Effort: 5 min.** + +### LOW-005 — `github.sh` "50+" is a page-size artefact +`gh api notifications --jq length` returns at most one page (50). The `>= 50` branch therefore +displays "50+" for exactly 50 as well as for 200. Correct but coincidental. Add `--paginate` or +document the heuristic. **Effort: 10 min.** + +### LOW-006 — No `set -u` in any plugin +No plugin sets `-u`. An unset `$NAME` (e.g. when run by hand for debugging) silently produces +`sketchybar --set "" ...`, which errors unhelpfully. Adding `set -u` would make manual invocation +fail fast. **Effort: 15 min across all plugins.** + +--- + +## 7. Low-Hanging Fruit + +Ordered by value ÷ effort. All are independent and none exceeds 30 minutes. + +| # | Action | Effort | Value | +|---|---|---:|---| +| 1 | `git add` + commit everything (**GIT-001/002**) | 20 min | Eliminates total-work-loss risk | +| 2 | `mic` `update_freq` 2 → 5 (**PERF-001 tier 1**) | 2 min | **−1,134 ms/min (−22% of total budget)** | +| 3 | `system` `update_freq` 30 → 60 (**PERF-002 interim**) | 2 min | **−930 ms/min (−18%)** | +| 4 | Fix the three false comments (**DOC-001**) | 10 min | Removes actively misleading docs | +| 5 | Enable the Raycast floating rule | 5 min | Ships a feature blocked by a stale comment | +| 6 | `/usr/local` PATH fallback (**PORT-001**) | 15 min | Unblocks Intel Macs | +| 7 | `curl -sf` in `weather.sh` | 2 min | Stops caching HTTP error pages | +| 8 | Privacy note in `weather.sh` (**SEC-001**) | 15 min | Makes a network behaviour discoverable | +| 9 | Correct the `mic.swift` "single-digit ms" claim | 5 min | Removes the false premise behind the worst perf bug | +| 10 | Drop `--monitor all` from `aerospace.sh` | 2 min | Dead argument on a single screen | + +**Items 2 and 3 alone cut the bar's CPU consumption by 40% for four minutes of work.** + +--- + +## 8. Suggested Refactors + +**R1 — Extract `lib.sh`** (ARCH-001). Prerequisite for R2 and SB-002. + +**R2 — Split `sketchybarrc` into `items/`** (ARCH-002). Depends on R1. Preserve add order explicitly. + +**R3 — Convert polled items to event-driven.** `mic` (PERF-001 tier 2) and `music` (SB-003). +Target: idle CPU below 1,500 ms/min, a 71% reduction. `volume` and `wifi` are the existing +in-repo precedents. + +**R4 — Add a linter and CI** (SB-001). The repo already has `.github/`; nothing uses it. + +--- + +## 9. Recommended Directory Structure + +``` +dotfiles/ +├── .github/workflows/lint.yml # NEW — runs scripts/lint-sketchybar.sh + shellcheck +├── scripts/ +│ └── lint-sketchybar.sh # NEW — the four silent-failure checks +├── docs/ +│ └── audit-2026-08-02.md # this file +├── sketchybar/.config/sketchybar/ +│ ├── sketchybarrc # ~120 lines +│ ├── colors.sh +│ ├── lib.sh # NEW +│ ├── items/ # NEW — one file per island +│ ├── plugins/ +│ └── helpers/ +└── (other stow packages unchanged) +``` + +`scripts/` sits outside the stow packages deliberately — it is repository tooling, not dotfiles, +and must not be symlinked into `$HOME`. + +--- + +## 10. Implementation Roadmap + +Ordered so no phase depends on a later one, and so file-level conflicts are minimised. + +### Phase 1 — Critical (≈ 1 h) +| ID | Task | Files | +|---|---|---| +| GIT-001 | Track `thermal.swift`; harden both helper guards | `install`, git index | +| GIT-002 | Commit the working tree in coherent units | all | +| DOC-001 | Fix three false comments | `system.sh`, `sketchybarrc`, `aerospace.toml` | + +*No file overlap with later phases except `sketchybarrc` comments — land first.* + +### Phase 2 — High-value, low-risk (≈ 2 h) +| ID | Task | Files | +|---|---|---| +| PERF-001 t1 | `mic` interval 2 → 5; correct the comment | `sketchybarrc`, `mic.swift` | +| PERF-002 int | `system` interval 30 → 60 | `sketchybarrc` | +| PERF-003 | Single-query workspace repaint | `aerospace.sh`, `sketchybarrc` | +| PORT-001 | Homebrew prefix fallback | `sketchybarrc`, `reload-sketchybar.sh` | +| SEC-001 | Weather privacy note + `curl -sf` | `weather.sh` | + +*Delivers the entire measured performance win. `sketchybarrc` is touched by three tasks — do them +in one commit.* + +### Phase 3 — Refactoring (≈ 6 h) +| ID | Task | Depends on | +|---|---|---| +| ARCH-001 | Extract `lib.sh`, migrate all 17 plugins | Phase 2 | +| SH-001 | Remove subprocesses; fix the AC-vs-charging bug | ARCH-001 | +| SB-002 | `require()` dependency guards | ARCH-001 | +| ARCH-002 | Split `sketchybarrc` into `items/` | ARCH-001 | +| SB-001 | Linter + CI | ARCH-001 | + +*High conflict surface. One task per commit; verify bar geometry unchanged after each.* + +### Phase 4 — Nice to have (≈ 5 h) +| ID | Task | +|---|---| +| PERF-001 t2 | Event-driven `mic` daemon | +| SB-003 | `media_change`-driven `music` | +| LOW-001…006 | Documentation and hardening | + +--- + +## 11. Final Verdict + +### Would I ship this? + +**As a personal configuration — yes, and I would run it daily.** It is more carefully reasoned +than most production infrastructure I have reviewed. The habit of recording *measurements* rather +than *intentions* in comments is genuinely rare and repeatedly paid off during this audit: several +findings were reachable only because a previous measurement was written down. + +**As an open-source project — not yet.** Three blockers: + +1. **It cannot be cloned successfully.** A required file is untracked and its absence is silent. +2. **It cannot run on a second machine.** Intel Macs fail on PATH; every plugin assumes Apple + Silicon Homebrew, and eight binary dependencies are unguarded. +3. **It has no automated verification of any kind.** For a configuration whose characteristic + failure mode is *silence*, the absence of a linter is the highest structural risk here. + +### Biggest risks + +**The documentation is load-bearing and drifting.** Three false comments were found in a +configuration where comments carry the measurements. This is the failure mode that will hurt most, +because the comments are precisely what makes the repository valuable. + +**Performance is unmonitored.** 8.6% of a core is being spent with no one watching. The `mic` +finding shows how it happened: a plausible-but-wrong comment ("single-digit ms") justified an +aggressive interval, and nothing ever re-checked it. + +### What fails after six months? + +- **`macmon`, `icalBuddy`, `gh`** — upstream changes break parsing; failures are silent. +- **Private APIs.** `IOHIDEventSystemClient` (thermal), `SLSSetWindowBackgroundBlurRadius` (pills), + `DisplayServices` (brightness) are all undocumented and all plausible macOS-update casualties. + Failure modes are contained, which is good design — but there are three of them. +- **The `~/Library/DoNotDisturb` layout** — already at `version 8`; Apple has changed it before. +- **TCC.** Any tightening of `~/Library` access silently degrades more plugins, as it already did + for the abandoned Focus indicator. + +### What should be rewritten, removed, simplified? + +**Rewritten:** `aerospace.sh` (per-item global queries → single-query repaint); `mic` (poll → +CoreAudio listener); `sketchybarrc` (monolith → `items/`). + +**Removed:** nothing functional. Four unused colour constants should be *kept* — a complete palette +is correct. Resist the urge to prune it. + +**Simplified:** the `hide()`/truncate/state-file/helper-build idioms into `lib.sh` — six duplication +sites collapse into four functions. + +### One thing to do today + +Commit the work, then change two numbers: +``` +mic update_freq 2 → 5 +system update_freq 30 → 60 +``` +That is four minutes for a **40% reduction in CPU consumption** and the elimination of total +work-loss risk. Everything else in this report can wait for a weekend. diff --git a/sketchybar/.config/sketchybar/helpers/thermal.swift b/sketchybar/.config/sketchybar/helpers/thermal.swift new file mode 100644 index 0000000..423e26f --- /dev/null +++ b/sketchybar/.config/sketchybar/helpers/thermal.swift @@ -0,0 +1,109 @@ +// Average SoC die temperature, in degrees Celsius. +// +// Prints one number to one decimal, or nothing at all with a non-zero exit when +// no usable sensor exists. Used by plugins/system.sh. +// +// WHY THIS AND NOT macmon's temp.cpu_temp_avg, which is what system.sh used to +// read: that value is not trustworthy on this machine. Measured inside a SINGLE +// macmon invocation, with CPU load flat at 1.5-3.7% and cpu_power at 0.06W: +// t+0s cpu=37.74 gpu=35.33 +// t+2s cpu=31.79 gpu=35.27 +// t+4s cpu=20.19 gpu=35.27 <- 17 degrees in two seconds, at idle +// A die cannot cool 17 degrees in two seconds and reheat. Across repeated trials +// the value is BIMODAL — it lands on either ~31.9 or ~38.1 and never in between, +// which is the signature of a mean taken over a VARYING SET of sensors rather +// than of anything thermal. macmon's gpu_temp_avg, from the same sample, is +// rock steady, and equals the tdie mean this file computes. +// +// WHAT THIS MACHINE ACTUALLY EXPOSES (39 services, 20 distinct names): +// PMU tdie1..tdie10 avg 35.25 max 36.11 the SoC die sensors +// PMU tdev1..tdev8 28.8 - 32.1 device/package +// PMU tcal 51.85 CALIBRATION, not a temperature +// NAND CH0 temp 29.0 SSD +// There is no sensor named "CPU". Apple Silicon puts CPU, GPU and ANE on one +// die, so a "CPU temperature" is always derived from die sensors — which is why +// this reports the die mean and calls it that. +// +// WHY IT NEEDS NO PERMISSION: this reads the HID temperature services, which are +// world-readable. It is not powermetrics and needs no sudo. +// +// WHY IT IS COMPILED: same reason as mic.swift — `swift` interpreting even a +// trivial script measured 1.25s there, which is not pollable. system.sh builds +// it on demand; the install script builds it up front. +// +// PRIVATE API. IOHIDEventSystemClient* is not public SPI. It is the standard +// no-sudo route to these sensors and is what every Apple Silicon temperature +// tool uses, but it is a plausible casualty of a macOS update. The failure mode +// is contained: this exits non-zero and system.sh drops the temperature from the +// label rather than showing a wrong one. + +import Foundation +import IOKit + +private typealias CreateFn = @convention(c) (CFAllocator?) -> AnyObject? +private typealias MatchFn = @convention(c) (AnyObject?, CFDictionary?) -> Void +private typealias ServicesFn = @convention(c) (AnyObject?) -> CFArray? +private typealias EventFn = @convention(c) (AnyObject?, Int64, Int32, Int64) -> AnyObject? +private typealias FloatFn = @convention(c) (AnyObject?, Int64) -> Double +private typealias PropFn = @convention(c) (AnyObject?, CFString) -> AnyObject? + +// kIOHIDEventTypeTemperature. The value is fetched with the type shifted into +// the high half, which is how IOHIDEventGetFloatValue addresses event fields. +private let kTemperature: Int64 = 15 + +private func sensors() -> [(name: String, value: Double)] { + guard let iokit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW) else { + return [] + } + func sym(_ name: String) -> T? { + guard let p = dlsym(iokit, name) else { return nil } + return unsafeBitCast(p, to: T.self) + } + guard let create: CreateFn = sym("IOHIDEventSystemClientCreate"), + let setMatching: MatchFn = sym("IOHIDEventSystemClientSetMatching"), + let copyServices: ServicesFn = sym("IOHIDEventSystemClientCopyServices"), + let copyEvent: EventFn = sym("IOHIDServiceClientCopyEvent"), + let floatValue: FloatFn = sym("IOHIDEventGetFloatValue"), + let copyProperty: PropFn = sym("IOHIDServiceClientCopyProperty") + else { return [] } + + let client = create(kCFAllocatorDefault) + // Usage page 0xff00 / usage 5 is the temperature sensor class. + setMatching(client, ["PrimaryUsagePage": 0xff00, "PrimaryUsage": 5] as CFDictionary) + guard let services = copyServices(client) as? [AnyObject] else { return [] } + + return services.compactMap { service in + guard let name = copyProperty(service, "Product" as CFString) as? String, + let event = copyEvent(service, kTemperature, 0, 0) + else { return nil } + return (name, floatValue(event, kTemperature << 16)) + } +} + +let all = sensors() + +// PREFERENCE ORDER, so this is not wired to one Apple Silicon generation. This +// machine only has the PMU names; other Macs expose per-cluster MTR sensors and +// would match the later rules instead. +// +// "PMU tcal" is excluded DELIBERATELY and is the reason this matches "PMU tdie" +// rather than the shorter "PMU t": tcal reads 51.85 here, is a calibration +// reference rather than a temperature, and would silently drag the mean up. +let rules: [(String, (String) -> Bool)] = [ + ("PMU tdie", { $0.hasPrefix("PMU tdie") }), + ("cluster MTR", { $0.contains("ACC MTR Temp") }), + ("SOC MTR", { $0.hasPrefix("SOC MTR") }), +] + +for (_, matches) in rules { + let values = all.filter { matches($0.name) }.map(\.value).filter { $0 > 0 } + if !values.isEmpty { + print(String(format: "%.1f", values.reduce(0, +) / Double(values.count))) + exit(0) + } +} + +// No usable sensor. Print NOTHING and fail — the caller has to be able to tell +// "no reading" apart from "zero degrees", which is the same rule volume.sh and +// brightness.sh follow. +exit(1) diff --git a/sketchybar/.config/sketchybar/plugins/system.sh b/sketchybar/.config/sketchybar/plugins/system.sh index 1ef3202..998a1aa 100755 --- a/sketchybar/.config/sketchybar/plugins/system.sh +++ b/sketchybar/.config/sketchybar/plugins/system.sh @@ -1,10 +1,28 @@ #!/usr/bin/env bash -# CPU load, memory, temperature and fan speed — every reading macmon gives us, -# from ONE sample. +# CPU load, memory, temperature and fan speed. +# +# TWO SOURCES, DELIBERATELY. macmon supplies load, memory and fan RPM from ONE +# sample; the temperature comes from helpers/thermal instead. macmon's +# temp.cpu_temp_avg used to drive this item and is not trustworthy — it is +# bimodal at flat idle and drops as far as 20°C. The full measurement is in the +# header of thermal.swift; the short version is that it is a mean over a varying +# set of sensors, so the bar was faithfully displaying a bogus number. source "$HOME/.config/sketchybar/colors.sh" -IFS=$'\t' read -r temp rpm cpu_pct ram_used ram_pct </dev/null | python3 -c ' import json, sys @@ -14,7 +32,6 @@ except Exception: print(0, -1, 0, 0, 0, sep=chr(9)) sys.exit() -temp = d.get("temp", {}).get("cpu_temp_avg") or 0 fans = d.get("fans", []) or [] # Fans are per-side and rarely equal; the louder one is the one you can hear. rpm = max((f.get("rpm", 0) for f in fans), default=0) if fans else -1 @@ -29,19 +46,43 @@ used = mem.get("ram_usage") or 0 gib = used / (1024 ** 3) pct = round(used * 100 / total) if total else 0 -print(round(temp), round(rpm), cpu, "%.1f" % gib, pct, sep=chr(9)) +print(1, round(rpm), cpu, "%.1f" % gib, pct, sep=chr(9)) ') EOF -if [ -z "$temp" ] || [ "$temp" -eq 0 ]; then +if [ "$ok" != "1" ]; then sketchybar --set thermals drawing=off --set cpu drawing=off --set memory drawing=off exit 0 fi -if [ "$temp" -ge 85 ]; then temp_color="$RED" -elif [ "$temp" -ge 70 ]; then temp_color="$PEACH" -elif [ "$temp" -ge 55 ]; then temp_color="$YELLOW" -else temp_color="$GREEN" +# Build the temperature helper on demand, exactly as mic.sh does, so a git pull +# that changes the source does not need a re-run of ./install. +if [ ! -x "$BIN" ] || [ "$SRC" -nt "$BIN" ]; then + if [ -r "$SRC" ] && command -v swiftc >/dev/null 2>&1; then + swiftc -O -o "$BIN.new" "$SRC" >/dev/null 2>&1 && mv "$BIN.new" "$BIN" || rm -f "$BIN.new" + fi +fi + +# A missing temperature must NOT hide the island. cpu and memory came from macmon +# and are still good; only the reading we could not take goes away. Same rule as +# volume.sh and brightness.sh — never state a value we do not know. +temp="$("$BIN" 2>/dev/null)" +case "$temp" in + ''|*[!0-9.]*) temp="" ;; +esac + +# The helper prints one decimal — precise enough to watch the die move while +# debugging, but the bar shows a whole number. A tenth of a degree is not +# information anyone acts on, and it keeps the pill one character narrower. +if [ -n "$temp" ]; then + temp="$(printf '%.0f' "$temp")" + if [ "$temp" -ge 85 ]; then temp_color="$RED" + elif [ "$temp" -ge 70 ]; then temp_color="$PEACH" + elif [ "$temp" -ge 55 ]; then temp_color="$YELLOW" + else temp_color="$GREEN" + fi +else + temp_color="$OVERLAY0" fi if [ "$cpu_pct" -ge 80 ]; then cpu_color="$RED" @@ -55,16 +96,39 @@ elif [ "$ram_pct" -ge 75 ]; then ram_color="$PEACH" else ram_color="$TEXT" fi -if [ -n "$rpm" ] && [ "$rpm" -ge 0 ]; then - thermal_label="${temp}° 󰈐 ${rpm}" -else +# ONE space each side of the fan glyph, not two. This read lopsided because it +# was, and the imbalance is in the glyph's own metrics rather than in the spaces. +# Measured per-glyph ink at label.font 13.5 (FiraCodeNF-Med), which is the face +# sketchybar actually resolves "FiraCode Nerd Font:Medium" to: +# ° ink ends 22.22, right side bearing 2.70 +# 󰈐 advance 8.31 but ink 11.26 wide — ZERO left side bearing, and it +# OVERFLOWS its own cell by ~2.95 on the right +# 1 left side bearing 1.12 +# So the fan glyph hugs whatever precedes it and crowds whatever follows: +# gap before = 2.70 + 8.31 + 0.00 = 11.01pt +# gap after = 8.31 - 2.95 + 1.12 = 6.48pt +# A second space added 8.31 to the gap that was ALREADY the larger one, making +# it 19.32 vs 6.48 — a 3:1 split, which is what the eye caught. +# The residual 4.5pt cannot be closed with space characters: FiraCode is +# monospaced and has no thin/hair space at all (U+2009, U+200A, U+2006 are +# absent; U+2008 exists but is a full 8.31 cell), so reaching for one would only +# trigger the font fallback this config warns about elsewhere. 8.31 is the only +# quantum available, and one is closer than two. +if [ -n "$temp" ] && [ -n "$rpm" ] && [ "$rpm" -ge 0 ]; then + thermal_label="${temp}° 󰈐 ${rpm}" +elif [ -n "$temp" ]; then thermal_label="${temp}°" +elif [ -n "$rpm" ] && [ "$rpm" -ge 0 ]; then + thermal_label="󰈐 ${rpm}" +else + thermal_label="—" fi sketchybar \ `# md-speedometer, not md-cpu_64_bit: cpu sits directly beside memory in` \ `# island.system, and md-cpu_64_bit is a detailed chip that reads as almost` \ - `# the same picture as md-memory at 13pt.` \ + `# the same picture as md-memory at icon sizes (13pt when this was chosen,` \ + `# 14.5 now — still too close to tell apart at a glance).` \ --set cpu drawing=on icon="󰓅" icon.color="$cpu_color" \ label.color="$TEXT" label="${cpu_pct}%" \ --set memory drawing=on icon="󰍛" icon.color="$ram_color" \ From 64ae6757574726ee23ee26f556b73c85f17687f8 Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:45:54 +0200 Subject: [PATCH 02/13] install: make a missing helper source loud instead of silent The two helper build blocks tested `[[ -r $src ]] && command -v swiftc` in one condition, so a MISSING SOURCE and a MISSING TOOLCHAIN were indistinguishable and both exited without printing anything -- no ok, no warn. thermal.swift was untracked in git, so a fresh clone hit exactly that path and produced a bar with no temperature and no explanation. Replace both with one build_sb_helper function carrying a three-way guard, so each outcome reports itself. --- install | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/install b/install index 1d35be7..78f62c1 100755 --- a/install +++ b/install @@ -444,20 +444,37 @@ fi # ============================================================ # Post-stow seeding # ============================================================ -# --- sketchybar mic helper --- +# --- sketchybar compiled helpers --- # Compiled, not interpreted: `swift ` on a trivial script measured 1.25s, -# which is not pollable. The binary is written next to the stowed source, which +# which is not pollable. Binaries are written next to the stowed source, which # lands in $HOME rather than in the repo because stow runs --no-folding (helpers/ -# is a real directory containing one symlink). mic.sh rebuilds it on demand too, -# so a `git pull` that changes the source doesn't need a re-run of this script. -mic_src="${HOME}/.config/sketchybar/helpers/mic.swift" -if [[ -r $mic_src ]] && command -v swiftc &>/dev/null; then - if swiftc -O -o "${mic_src:h}/mic" "$mic_src" 2>/dev/null; then - ok "sketchybar mic helper built." +# is a real directory containing symlinks). Both callers (mic.sh, system.sh) +# rebuild on demand too, so a `git pull` that changes a source doesn't need a +# re-run of this script. +# +# THREE-WAY GUARD, DELIBERATELY. An earlier version tested +# `[[ -r $src ]] && command -v swiftc` in one condition, which meant a MISSING +# SOURCE was indistinguishable from a missing toolchain and both exited silently +# — no ok, no warn, nothing. thermal.swift was untracked in git for a while, and +# a fresh clone would have produced a bar with no temperature and no explanation. +# Distinguish the three outcomes so absence is loud. +build_sb_helper() { + local name=$1 what=$2 + local src="${HOME}/.config/sketchybar/helpers/${name}.swift" + + if [[ ! -r $src ]]; then + warn "sketchybar ${name} helper source missing at ${src}; ${what}" + elif ! command -v swiftc &>/dev/null; then + warn "swiftc not found, so the sketchybar ${name} helper was not built; ${what}" + elif swiftc -O -o "${src:h}/${name}" "$src" 2>/dev/null; then + ok "sketchybar ${name} helper built." else - warn "sketchybar mic helper failed to build; the mic item will stay hidden." + warn "sketchybar ${name} helper failed to build; ${what}" fi -fi +} + +build_sb_helper mic "the mic item will stay hidden." +build_sb_helper thermal "the temperature will be omitted from the system island." # --- btop theme --- # btop.conf is machine-local (btop rewrites it on every exit) so it isn't tracked. From fb5c360e4e3c7a1ab6bc9cfc1b5c2158d6c076c2 Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:45:54 +0200 Subject: [PATCH 03/13] sketchybar: document weather's IP geolocation, and fail closed on HTTP errors sketchybarrc pointed at "the privacy note in weather.sh"; no such note existed. With LOCATION empty, wttr.in geolocates by SOURCE IP, so every poll discloses the host's public IP to a third party. That is a fair trade for zero configuration but it is not visible from the URL. Write the note, and add SKETCHYBAR_WEATHER_LOCATION so the behaviour can be pinned to a place or turned off. Also curl -sf: without -f, curl exits 0 on a 5xx and the error page is written to the cache, which is then served as the last-known-good reading indefinitely. --- .../.config/sketchybar/plugins/weather.sh | 25 +- sketchybar/.config/sketchybar/sketchybarrc | 633 +++++++++++------- 2 files changed, 432 insertions(+), 226 deletions(-) diff --git a/sketchybar/.config/sketchybar/plugins/weather.sh b/sketchybar/.config/sketchybar/plugins/weather.sh index 0e46e73..c04ffe7 100755 --- a/sketchybar/.config/sketchybar/plugins/weather.sh +++ b/sketchybar/.config/sketchybar/plugins/weather.sh @@ -1,12 +1,33 @@ #!/usr/bin/env bash # Current conditions from wttr.in. +# +# PRIVACY — read this before copying the file. With LOCATION empty, wttr.in +# GEOLOCATES BY SOURCE IP: every poll discloses this host's public IP to a third +# party and returns location-correlated data. That is a deliberate trade for +# zero configuration, but it is not obvious from the URL, and sketchybarrc's +# comment on this item points here for exactly this paragraph. +# +# Three ways to change it: +# SKETCHYBAR_WEATHER_LOCATION="Copenhagen" send an explicit place instead — +# still a third-party request, but no +# IP-based inference +# SKETCHYBAR_WEATHER_LOCATION="off" disable the item entirely +# (unset) current behaviour, IP geolocation source "$HOME/.config/sketchybar/colors.sh" -LOCATION="" +LOCATION="${SKETCHYBAR_WEATHER_LOCATION:-}" CACHE="${TMPDIR:-/tmp}/sketchybar-weather" -reading="$(curl -s --max-time 5 "https://wttr.in/${LOCATION}?format=%C|%t" 2>/dev/null)" +if [ "$LOCATION" = "off" ]; then + sketchybar --set "$NAME" drawing=off + exit 0 +fi + +# -f so an HTTP error is a FAILURE rather than a body. Without it curl exits 0 +# on a 5xx and the error page lands in the cache, which then gets served as the +# last-known-good reading indefinitely. +reading="$(curl -sf --max-time 5 "https://wttr.in/${LOCATION}?format=%C|%t" 2>/dev/null)" case "$reading" in *"|"*"°"*) printf '%s' "$reading" > "$CACHE" ;; diff --git a/sketchybar/.config/sketchybar/sketchybarrc b/sketchybar/.config/sketchybar/sketchybarrc index 0cd13d3..cb77a2c 100755 --- a/sketchybar/.config/sketchybar/sketchybarrc +++ b/sketchybar/.config/sketchybar/sketchybarrc @@ -4,109 +4,53 @@ # ============================================================ # Docs: https://felixkratz.github.io/SketchyBar/ # -# WHY THIS EXISTS: with the Dock auto-hidden and nine numeric workspaces spread -# over three monitors, there was no way to see which workspaces held windows -# without switching to each one to look. The workspace items on the left are the -# point; everything else is filler. +# WHY THIS EXISTS: with the Dock auto-hidden and nine numeric workspaces, there +# was no way to see which workspaces held windows without switching to each one +# to look. The workspace items on the left are the point; everything else is +# filler. # # Started by aerospace's after-startup-command, next to borders — not by # `brew services`, so the bar's lifetime is tied to the window manager's. # To poke at it by hand: `pkill -x sketchybar` then `sketchybar &`. # -# SHAPE: the bar itself is transparent. The three brackets at the bottom of this -# file are the only backgrounds drawn — a workspace island, a front-app island -# and a status island. Nothing is ever placed in the CENTRE, and that is what -# makes the CENTRE notch-safe: the built-in display's notch is 185pt wide -# (measured, 1512 - 663 - 664 via NSScreen auxiliaryTopLeft/RightArea) and it -# cuts through empty transparent space rather than through an item. Put a centre -# item here and the laptop display is the one that breaks. -# -# WIDTH BUDGET — check any new item against this before adding it. Neither EDGE -# is automatically safe; only the centre is. The built-in is 1512pt wide and the -# notch spans x=663..849, which splits the usable bar into two runs: -# -# LEFT 396pt front-app island ends x≈255, notch starts x≈663, less a -# 12pt spacer. Grows rightward. -# RIGHT 655pt bar edge x=1504, notch ends x≈849. Grows leftward. -# -# Measured item widths, for estimating: amphetamine 24 (icon only), brightness -# 53, battery 54, volume 65, wifi 68, clock 154, bluetooth 95 uncapped, music -# 269 at MAX_LEN=32. Roughly 8pt per label character. -# -# LEFT, measured on the built-in. island.media is a CONSTANT 162pt — music has a -# fixed width with scrolling text, so its size no longer depends on what is -# playing. Only island.system still moves, because it carries the fan RPM at all -# times (including the 0 Apple Silicon reads at idle) and the digit count varies: -# idle island.system 202..407 (205pt) island.media 419..581 clear 82pt -# under load island.system 202..435 (233pt) island.media 447..609 clear 54pt -# The front-app island used to sit between the workspaces and island.system; it -# is commented out, which is what moved everything 65pt left and turned what was -# a 10pt squeeze into 82pt of room. Re-enable it and those numbers go back to -# 267..479 / 491..653, which still fits — but only just. -# -# RIGHT, four islands, measured left to right on screen. Re-measured after the -# switch to inset items, in the ordinary state — calendar, github, mic and vpn -# hidden, weather showing: -# island.status 928..1037 109pt weather github mic amphetamine pomodoro -# island.network 1049..1149 100pt wifi bluetooth vpn -# island.hardware 1161..1332 171pt battery brightness volume -# island.time 1344..1504 160pt calendar clock -# Leftmost edge 928 against a notch ending at 848.5 — 79pt of clearance here. -# -# The inset items cost at most 6pt per island END, and only in the states where -# that end's member is hidden — which are precisely the roomy states. Where both -# ends are visible they replace the background.padding they displaced and cost -# nothing, so the crowded case below is unaffected. Measured: time and network -# each grew 6pt, hardware grew 0. -# -# Splitting one island into four cost 72pt: 12pt of spacer plus 12pt of interior -# padding for each of the three new ones. That is the price of the grouping and -# it is charged whether or not the members are visible, because the spacers are -# ordinary items with a fixed width. -# -# The tail is still the constraint. Measured before the split, cumulatively: -# steady state, every transient hidden 527pt clear 128pt -# + calendar (an event still to come today) 592pt clear 64pt -# + pomodoro running 634pt clear 22pt -# + a bluetooth device name 707pt OVER 52pt -# + vpn connected, mic live, unread notifications 861pt OVER 205pt -# Add 72 to every row for the current layout. Three ordinary things at once -# already touches the notch and everything at once overruns badly. This is a real -# limit, not a rounding error — do not add another always-drawing item to the -# right side, or another island, without taking width back first. -# -# Where the width is, in order of how much it buys: -# clock 154pt -> 65 as %H:%M. The single biggest reclaim available, -# kept at full date format by explicit choice. -# bluetooth 73pt -> 24 by dropping the device name (MAX_LEN in -# bluetooth.sh) and showing the icon alone. -# calendar 65pt -> 0 on the laptop via `display=`, which scopes an -# weather 65pt item to a display index. The two externals are -# github 45pt 2560pt wide with ~1000pt spare and never overrun, -# so scoping the optional items to them costs nothing -# there. Caveat: display indices shift when monitors -# are unplugged, so this is a per-machine tweak and -# not something to bake in blindly. -# -# HEIGHT vs. THE NOTCH vs. GAPS — four settings that must move together: -# 1. this bar's height (32) and `notch_display_height` (32). 32 is the -# measured height of the built-in's notch band (safeAreaInsets.top), so the -# bar fills it exactly and the islands sit at the same y on the laptop as -# on the externals. -# 2. the islands' `background.height` (26), centred in that 32 — so their -# bottom edge lands at y=29. -# 3. `gaps.outer.top` in aerospace.toml: 37 (= 29 + the usual 8pt gap) on the -# externals, 5 on the built-in. macOS reserves the built-in's top 32pt as -# notch safe area even with the menu bar hidden (visibleFrame is 1512x950 -# of a 1512x982 frame) and AeroSpace tiles below that band, so it needs -# only the remaining 5. Get this wrong and windows either slide under the -# islands or leave a dead strip on one display but not the others. -# 4. the macOS menu bar auto-hidden (`_HIHideMenuBar`, set by the install -# script). With it visible, it reserves ~30pt and pushes this bar down -# below it — measured, origin y=29/31 on the displays that carry one, y=0 -# on the one that doesn't. -# Change the height and 37/5 stop matching. Re-show the menu bar and the bar -# stops sitting at the top of the screen. +# ONE SCREEN. This config is tuned for a single 2560x1440 external and carries +# no notch arithmetic, no per-display scoping and no width budget. It used to: +# on a 1512pt laptop the notch split the bar into two ~655pt runs and the right +# one was genuinely oversubscribed, which drove several decisions here. All of +# that was removed. If a notched display ever comes back, the measurements are +# in git history — do not re-derive them. +# +# SHAPE: the bar itself is transparent. The brackets at the bottom of this file +# are the only backgrounds drawn — three islands on the left (workspaces, front +# app, music) and four on the right (system, status, network, hardware, time). +# They are frosted individually; the bar behind them is not. +# +# Nothing is placed in the CENTRE. That is now a layout preference rather than a +# constraint: the runs hug the edges and the middle stays clear, which is what +# makes the bar read as two groups instead of one long strip. +# +# GEOMETRY, measured on this display. Only two things move: island.system with +# the fan RPM digit count, and island.app with the focused app's name (front_app +# applies no MAX_LEN, so it is ~8.1pt per character plus 24). +# island.spaces 8..195 187pt island.system 1783..2013 230pt +# island.app 207..264 57pt island.status 2025..2157 132pt +# island.media (hidden) 192pt island.network 2169..2275 106pt +# island.hardware 2287..2365 78pt +# island.time 2377..2552 175pt +# ~1200pt of dead space between the runs, so nothing here is width-constrained. +# Add what you like; just keep the middle clear. +# +# HEIGHT vs. GAPS — three settings that must move together: +# 1. this bar's height (38). A legibility choice, not a measured constant. +# 2. the islands' `background.height` (32), centred in that 38 — so their +# bottom edge lands at y=35. +# 3. `gaps.outer.top` in aerospace.toml: 43, i.e. 35 + the usual 8pt gap. Get +# this wrong and windows either slide under the islands or leave a dead +# strip below them. +# ...and the macOS menu bar auto-hidden (`_HIHideMenuBar`, set by the install +# script). With it visible it reserves ~30pt and pushes this bar down below +# it rather than to the top of the screen. +# Change the height and 43 stops matching. CONFIG_DIR="$HOME/.config/sketchybar" PLUGIN_DIR="$CONFIG_DIR/plugins" @@ -122,18 +66,21 @@ FONT="FiraCode Nerd Font" # --- Bar --------------------------------------------------- # Transparent: the islands at the bottom of this file draw everything visible. +# DO NOT put a colour or a blur here. A full-width frosted band was tried and +# rejected — blur_radius on the BAR frosts the whole rectangle, gaps included, +# which is the effect the islands exist to avoid. The pills carry their own blur +# instead; see ISLAND_STYLE. # padding 8 matches aerospace's gaps.outer.left/right, so the islands line up # with the window column underneath. -# notch_width only affects CENTRE items, of which there are none — it's a safety -# net for the day someone adds one. Measured notch is 185; 200 is the default -# and leaves margin. -# notch_display_height is redundant while height is also 32, but it's set -# explicitly so the "bar fills the notch band" invariant survives someone -# retuning height. +# +# The notch properties (notch_width, notch_display_height, notch_offset) were +# set here and are gone — they only do anything on a display with a notch, and +# there is not one. Worth knowing if they ever come back: NONE of them are echoed +# by `sketchybar --query bar`, the same silent-property trap as label.max_chars +# on the music item, so a wrong value looks exactly like a right one. The only +# feedback is that an invalid property errors on --bar. sketchybar --bar \ - height=32 \ - notch_display_height=32 \ - notch_width=200 \ + height=38 \ position=top \ sticky=on \ padding_left=8 \ @@ -208,7 +155,7 @@ sketchybar --bar \ # 54pt, with 12pt from each bracket edge to the label's ink. sketchybar --default \ updates=when_shown \ - icon.font="$FONT:Medium:13.0" \ + icon.font="$FONT:Medium:14.5" \ icon.color="$TEXT" \ icon.padding_left=6 \ icon.padding_right=3 \ @@ -218,6 +165,9 @@ sketchybar --default \ `# high — 'Fri 31 Jul 23:26' by +1.06pt, '65%' by +0.54pt.` \ `# Cause: text_calculate_bounds centres each run by FONT METRICS, not ink —` \ `# origin.y = y - (line.ascent - line.descent) / 2` \ + `# The numbers below were measured at the ORIGINAL 13.0/12.0 sizes. They` \ + `# still hold in shape at 14.5/13.5 — the offset scales with the point size,` \ + `# so the ideal is now ~1.125 and 1 is still the nearest integer.` \ `# The 12pt face reports ascent 11.08 / descent 3.69, so the box reserves` \ `# descender room. A label with no descender fills only the top of that box,` \ `# and metric-centring lifts it. Nerd Font's icons are drawn centred on the em` \ @@ -233,15 +183,15 @@ sketchybar --default \ `# tall label and 0.46pt off '65%'. Both are better than before; ~1 physical` \ `# pixel of spread is inherent. Set 0 to restore the old behaviour exactly.` \ icon.y_offset=1 \ - label.font="$FONT:Medium:12.0" \ + label.font="$FONT:Medium:13.5" \ label.color="$TEXT" \ label.padding_left=3 \ label.padding_right=6 \ `# The focused-workspace pill (aerospace.sh is the only thing that turns` \ `# this on). radius is half of height, which is what makes it a pill and` \ - `# not a rounded rectangle; 20 inside the islands' 26 insets it by 3.` \ - background.corner_radius=10 \ - background.height=20 \ + `# not a rounded rectangle; 26 inside the islands' 32 insets it by 3.` \ + background.corner_radius=13 \ + background.height=26 \ background.color="$MAUVE" \ background.drawing=off @@ -327,79 +277,69 @@ sketchybar \ --set inset.spaces.r "${BLANK_STYLE[@]}" width=6 # --- Front app --------------------------------------------- -# --- Front app (disabled) ---------------------------------- -# Commented out to reclaim its 65pt on the left run (53pt item + the 12pt spacer -# that separated it from the workspaces). The focused app is already legible from -# the window itself; the workspace indicators are what this bar exists for. +# --- Front app --------------------------------------------- +# Name of the focused app. # -# To restore: uncomment BOTH blocks below AND the island.app bracket further -# down. Order matters — left items render in the order they are added, so -# spacer.apps and front_app have to stay between the workspace loop above and the -# system metrics below. front_app.sh is still stowed and untouched. +# ORDER IS THE LAYOUT on this side too, but the other way round from the right: +# left items render in ADD order, so first added sits furthest LEFT. These two +# blocks therefore have to stay between the workspace loop above and the +# now-playing block below to read [spaces] [app] [media]. (The note that used to +# live here said "and the system metrics below" — those are on the right now.) # # Note the spacer: a bracket's rect runs to the outer edge of its first and last # member, so an item INSIDE a group can never open a gap between groups — only an -# item outside both can. drawing stays on (the default), because an item with -# drawing=off contributes no width at all and the islands would touch. -# -# sketchybar \ -# --add item spacer.apps left \ -# --set spacer.apps \ -# icon.drawing=off \ -# label.drawing=off \ -# background.drawing=off \ -# width=12 -# -# sketchybar \ -# --add item front_app left \ -# --subscribe front_app front_app_switched \ -# --set front_app \ -# icon.drawing=off \ -# label.color="$SUBTEXT0" \ -# label.padding_left=6 \ -# `# Sole member of its island, so it carries both ends' padding.` \ -# background.padding_left=6 \ -# background.padding_right=6 \ -# script="$PLUGIN_DIR/front_app.sh" - -# --- System metrics ---------------------------------------- -# These live on the LEFT because the right side is up against the notch and this -# side has ~200pt spare — see the width budget at the top of this file. -# -# ONE SAMPLE, THREE ITEMS: a macmon sample costs ~0.9s, so only `thermals` runs -# system.sh; it writes cpu and memory too. cpu and memory are passive — no -# script, no update_freq — and would never update on their own. -sketchybar \ - --add item spacer.system left \ - --set spacer.system "${BLANK_STYLE[@]}" width=12 - -sketchybar \ - --add item cpu left \ - --set cpu \ - `# background.padding, NOT a pad item — island.system is one of the two` \ - `# islands that must be able to collapse, and a pad would never hide.` \ - `# Safe here because all three members hide together, so the ends can` \ - `# never drift. See ISLAND PADDING at the top.` \ +# item outside both can. BLANK_STYLE draws nothing but keeps its width, which is +# the point — an item with drawing=off contributes no width at all and the +# islands would touch. +sketchybar \ + --add item spacer.apps left \ + --set spacer.apps "${BLANK_STYLE[@]}" width=12 + +# THIS ISLAND CANNOT COLLAPSE, so background.padding on the sole member is safe. +# front_app.sh always sets a label — it falls back to "—" when aerospace cannot +# name the focused window — and never sets drawing=off, so island.app is always +# drawn and spacer.apps can never be orphaned. +# +# The usual objection to background.padding (see ISLAND PADDING at the top) is +# that a hidden end member takes its padding with it and the interior silently +# halves. That cannot happen here: with ONE member there is no other item that +# could become the end, and if it ever did hide the whole bracket would go with +# it. Pad items would buy nothing and cost two more items. +# +# WIDTH IS UNBOUNDED, unlike every other island on this run. front_app.sh applies +# no MAX_LEN, so the pill tracks the app name: ~8.1pt per character plus 12pt of +# label padding and 12pt of interior. "kitty" ~64pt, "Google Chrome" ~129pt, +# "IntelliJ IDEA Ultimate" ~202pt. There is ~1200pt of clear space to its right +# so none of that matters here — but it IS the only thing on this side that +# moves, and a MAX_LEN in front_app.sh is the lever if it ever reads too wide. +sketchybar \ + --add item front_app left \ + --subscribe front_app front_app_switched \ + --set front_app \ + icon.drawing=off \ + label.color="$SUBTEXT0" \ + `# The icon is off, so the label's padding IS the outer edge — 6/6 keeps` \ + `# the 12pt spacing contract. padding_right is already 6 by default.` \ + label.padding_left=6 \ + `# Sole member of its island, so it carries both ends' padding.` \ background.padding_left=6 \ - --add item memory left \ - --add item thermals left \ - --subscribe thermals system_woke \ - --set thermals \ - `# Last member of island.system — carries its right interior padding.` \ background.padding_right=6 \ - `# updates=on, NOT the config-wide when_shown default: system.sh hides` \ - `# all three items when macmon fails, and a hidden item under when_shown` \ - `# stops being polled — the failure would be permanent and silent.` \ - updates=on \ - update_freq=30 \ - script="$PLUGIN_DIR/system.sh" \ - click_script="open -a 'Macs Fan Control'" + script="$PLUGIN_DIR/front_app.sh" + +# --- System metrics ---------------------------------------- +# MOVED TO THE RIGHT SIDE — see the SYSTEM block at the end of the right-side +# sequence below. The left run is now just the workspaces and the music island. # --- Now playing ------------------------------------------- # Apple Music now-playing, in its own island so it can vanish cleanly: a bracket # whose every member is hidden goes to g_nirvana and draws nothing, leaving no # empty pill behind. Right-click opens a transport popup, which costs no bar # width — that is why prev/next are not inline items. +# +# spacer.media now separates this island from island.spaces rather than from +# island.system, which moved right. It is still needed for exactly the same +# reason: a bracket's rect runs to the outer edge of its end members, so only an +# item belonging to NEITHER bracket can open a gap between two islands. sketchybar \ --add item spacer.media left \ --set spacer.media "${BLANK_STYLE[@]}" width=12 @@ -414,12 +354,12 @@ sketchybar \ `# an empty pill on screen. See ISLAND PADDING at the top.` \ background.padding_left=6 \ background.padding_right=6 \ - `# FIXED WIDTH + SCROLLING TEXT. This is what makes the left run's` \ - `# geometry deterministic: island.media is always the same size whatever` \ - `# is playing, so notch clearance is a constant instead of something that` \ - `# depends on the current track title. It replaces a MAX_LEN cut in` \ - `# music.sh, which capped CHARACTERS against a budget measured in POINTS` \ - `# and so overflowed on wide titles and wasted room on narrow ones.` \ + `# FIXED WIDTH + SCROLLING TEXT. island.media is the same size whatever is` \ + `# playing, so the left run does not reflow every time the track changes` \ + `# — which is the real win, since island.app beside it already moves with` \ + `# the app name. It replaces a MAX_LEN cut in music.sh, which capped` \ + `# CHARACTERS against a budget measured in POINTS and so overflowed on` \ + `# wide titles and wasted room on narrow ones.` \ `#` \ `# FOUR properties, and missing any one of them fails quietly:` \ `# label.max_chars THE ONE THAT DRIVES IT. sketchybar scrolls text` \ @@ -431,9 +371,9 @@ sketchybar \ `# label.width fixes the visible text box, so the pill is the same` \ `# size whatever is playing. Without it the label` \ `# renders at its natural extent — measured, the` \ - `# bracket spanned 303pt and ran 130pt into the notch` \ - `# even with item width= set, because group bounds come` \ - `# from the label, not from the item.` \ + `# bracket spanned 303pt even with item width= set,` \ + `# because group bounds come from the label, not from` \ + `# the item.` \ `# width pins the pill so short titles don't shrink it.` \ `#` \ `# label.max_chars is NOT echoed by \`sketchybar --query\`, so a missing or` \ @@ -446,23 +386,61 @@ sketchybar \ `# perfectly still. It is not "scroll when the text overflows"; it is the` \ `# other way round, and getting it backwards costs an hour.` \ `#` \ - `# Natural label width per max_chars at this font, measured by setting both` \ - `# width and label.width to dynamic and reading the bracket (the fixed` \ - `# width=150 floor hides everything below 18 chars, so it has to come off` \ - `# first or the table reads as a flat line):` \ + `# THE GATE, in src/text.c:277 — text_animate_scroll() opens with` \ + `# if (has_const_width && custom_width < width) return false;` \ + `# where \`width\` is the max_chars-TRUNCATED width and \`custom_width\` is` \ + `# label.width. So label.width must be >= the truncated text, and when it` \ + `# is not you get a still, clipped label rather than any error.` \ + `#` \ + `# Truncated ink per max_chars, MEASURED AGAINST THE RUNNING BAR at` \ + `# label.font 13.5 — a probe item with label.width dynamic and a long` \ + `# label, reading back the item width and subtracting its 9pt of label` \ + `# padding (icon.drawing=off, so the icon contributes nothing):` \ + `# chars 10 12 13 14 16 18 20` \ + `# points 83 92 108 116 133 142 166` \ + `# ~8.1pt per character, because FiraCode is MONOSPACED — every 16-char` \ + `# string lands within a few pt of every other (measured across real` \ + `# titles: 123, 131, 132, 134). The small jitter is side bearings at the` \ + `# truncation point, not content width.` \ + `#` \ + `# 16 chars = 133pt in a 146pt box: 13pt of slack, so a title of unusually` \ + `# wide glyphs still fits and keeps scrolling. That slack is deliberate —` \ + `# clipping is the failure mode, so err generous.` \ + `#` \ + `# THIS TABLE REPLACED AN EARLIER ONE THAT WAS WRONG, and the way it was` \ + `# wrong is worth keeping. It read` \ `# chars 10 12 13 14 16 18 20` \ `# points 56 71 71 86 100 115 115` \ - `# 13 chars = 71pt in a 105pt box scrolled but left 34pt of the pill dead.` \ - `# 18 chars = 115pt in a 110pt box overflowed and stopped scrolling.` \ - `# 16 chars = 100pt in a 112pt box: fills it, with 12pt of slack so that a` \ - `# window of unusually wide glyphs still fits and keeps scrolling. That` \ - `# slack is deliberate — clipping is the failure mode, so err generous.` \ + `# — note 12 and 13 tie, and 18 and 20 tie. A monospaced face cannot do` \ + `# that; the plateaus are a fixed-width floor clamping the reading, which` \ + `# is exactly the trap its own note warned about and then fell into. It` \ + `# undersold 16 chars by ~33pt, which is how both 112 and its scaled` \ + `# successor 126 came to be set BELOW the text they had to contain. This` \ + `# item most likely never scrolled until 146.` \ + `#` \ + `# MEASURE AGAINST FiraCodeNF-Med, NOT "FiraCode Nerd Font Medium". That` \ + `# name does not resolve: CoreText silently falls back to HELVETICA and` \ + `# only maps the Nerd Font PUA glyphs to FiraCodeNF-Reg, so a measurement` \ + `# taken that way is of the wrong face entirely and no error is raised.` \ + `# The tell is the one above — a proportional face makes 16-char strings` \ + `# range wildly (47..204pt was the bogus reading) where a monospaced one` \ + `# cannot. Better still, measure the running bar with a probe item as the` \ + `# table above does, which cannot pick the wrong font by construction.` \ `#` \ - `# Sized from the notch: island.system ends at 415, this island starts at` \ - `# 427, the notch starts at 663. There is ~220pt of room here now that the` \ - `# front-app island is gone, so 150 is comfortable rather than a squeeze.` \ - width=160 \ - label.width=112 \ + `# THE TABLE IS FONT-DEPENDENT, so RETUNING label.font BREAKS THIS ITEM` \ + `# unless label.width moves with it: the points scale linearly with the` \ + `# point size while max_chars does not.` \ + `#` \ + `# 180 is a comfort choice, not a constraint — the left run ends around` \ + `# x=456 with ~1200pt clear to its right, so there is plenty of headroom` \ + `# to widen this (raise max_chars AND label.width together, per the rule` \ + `# above; raising one alone silently stops the scroll).` \ + `#` \ + `# width stays 180 while label.width grew to 146: measured with a probe at` \ + `# label.width=146, the natural content width is 163pt, so the 180pt pin` \ + `# still contains it and the pill does not need to grow.` \ + width=180 \ + label.width=146 \ label.max_chars=16 \ scroll_texts=on \ label.scroll_duration=100 \ @@ -486,26 +464,62 @@ sketchybar \ click_script="osascript -e 'tell application \"Music\" to next track' >/dev/null 2>&1; sketchybar --set music popup.drawing=off" # --- Right side -------------------------------------------- -# FOUR islands, grouped by category. Right items render in ADD order, so the +# FIVE islands, grouped by category. Right items render in ADD order, so the # first item added sits furthest right and the reading order below is the mirror # of what you see: # -# screen: [ status ] [ connectivity ] [ hardware ] [ time ] -# added: time -> hardware -> connectivity -> status +# screen: [ system ] [ status ] [ network ] [ hardware ] [ time ] +# added: time -> hardware -> network -> status -> system +# +# THE ADD ORDER IS THE LAYOUT. On this side "added earlier" means "further +# right", which inverts two things people get wrong when moving an item here from +# the left (island.system was moved exactly this way): +# - a SPACER has to be added BEFORE the island it sits to the right of, not +# after it. +# - an island's MEMBERS have to be added in reverse of their screen order, so +# `cpu memory thermals` left-to-right is added thermals, memory, cpu. +# background.padding_left/right do NOT invert — they stay screen-space. Verified +# in src/bar.c: a POSITION_RIGHT item walks leftward via +# `next_position -= length + padding_right` and then `-= padding_left`. # # volume and wifi ride native events (volume_change, wifi_change) and never poll; # the rest are on timers, because macOS publishes no event for any of them. # -# EVERY ISLAND NEEDS AN ITEM THAT NEVER HIDES. A bracket whose members have all -# hidden collapses to g_nirvana and draws nothing — but the spacer beside it is a -# separate item and stays, so the bar is left with an orphaned 12pt gap. The four -# anchors are clock, volume, wifi and pomodoro; the other nine items can all hide -# themselves (battery, brightness, amphetamine, bluetooth, calendar, weather, -# github, vpn, mic). Do not regroup these without checking each island keeps one. +# EVERY ISLAND NEEDS AN ITEM THAT NEVER HIDES, with one deliberate exception. A +# bracket whose members have all hidden collapses to g_nirvana and draws nothing — +# but the spacer beside it is a separate item and stays, so the bar is left with +# an orphaned 12pt gap. The four anchors are clock, volume, wifi and pomodoro. +# +# EIGHT ITEMS SET drawing=off ON THEMSELVES, AND EVERY ONE NEEDS updates=on: +# battery brightness bluetooth calendar weather github vpn mic +# (amphetamine is NOT one of them — it only hides its LABEL, never the item, so +# when_shown is correct there. Check for item-level `drawing=off` in the plugin +# before assuming.) +# +# WHY: a self-hiding item under the config-wide when_shown default cannot +# un-hide. bar_item_update() gates timed AND event runs behind +# `updates_only_when_shown ? is_shown : true`, and bar_draw() clears the bar +# association of anything it does not draw. The item works right after a reload, +# hides itself when there is nothing to show, and then never runs again — +# silently. Seven were found in that state or heading for it and fixed here; +# volume and brightness were fixed earlier. weather is the sharpest example: it +# hides on a failed fetch, so one flaky request retired it for good. +# If you add a hideable item, this is the property you will forget. +# island.system is the exception — it EXISTS to collapse (system.sh hides all +# three members when macmon fails) and so has no anchor by design. That is safe +# only because it is the OUTERMOST island on this side: its orphaned spacer is +# then empty space at the leading edge of the run, where nothing can see it. +# Move island.system inboard and the orphan becomes a visible gap between two +# drawn islands, which is what it used to be on the left. +# Do not regroup these without checking each island keeps one. +# CAVEAT on island.hardware: volume is NOT a dependable anchor and the old note +# here — "volume.sh does hide it if the level is unreadable, which is a failure +# state rather than a normal one" — was wrong. Unreadable is the NORMAL state for +# a class-compliant USB audio interface, which has no software volume at all, so +# this island is really anchored by brightness alone whenever one is in use. # The pad items below do NOT count as anchors — they hold each island's interior # padding, not its reason to exist, and an island of nothing but pads would be a -# 12pt empty pill. volume is the weakest of the four: volume.sh does hide it if -# the level is unreadable, which is a failure state rather than a normal one. +# 12pt empty pill. # # EACH ISLAND IS BRACKETED BY AN INSET ITEM, inset..l / .r, which is what # keeps its interior at 12pt on both ends no matter which members are hidden. @@ -546,6 +560,8 @@ sketchybar \ popup.background.border_width=1 \ popup.background.border_color="$ISLAND_BORDER" \ popup.background.corner_radius=10 \ + `# SELF-HIDING ITEM -> updates=on. See the volume item.` \ + updates=on \ update_freq=300 \ script="$PLUGIN_DIR/calendar.sh" \ click_script="open -a Calendar" \ @@ -569,16 +585,38 @@ sketchybar \ --add item battery right \ --subscribe battery power_source_change system_woke \ --set battery \ + `# SELF-HIDING ITEM -> updates=on. See the volume item. This one is easy` \ + `# to dismiss as harmless because the item is hidden while on AC — but` \ + `# that is exactly the state it is in when you unplug, and without this` \ + `# it would never notice.` \ + updates=on \ update_freq=120 \ script="$PLUGIN_DIR/battery.sh" -# Built-in display brightness. Subscribed to the native event for instant -# response, with a 5s poll behind it — the event is not guaranteed to fire for -# every brightness source, and the ioreg read costs 0.01s. +# Brightness of whatever display is MAIN — not just the built-in, despite what +# this comment used to say. brightness.sh branches on CGDisplayIsBuiltin: a real +# backlight is read from DisplayServices, an external is read back off its GAMMA +# RAMP, which is what MonitorControl's software dimming actually manipulates. +# See the long note at the top of that script for why DDC is the wrong source. +# +# Subscribed to the native event for instant response, with a 5s poll behind it — +# the event is not guaranteed to fire for every brightness source, and both reads +# are cheap. The 5s poll is what carries MonitorControl, which emits no event at +# all: nothing tells sketchybar that a third-party app rewrote the gamma table. sketchybar \ --add item brightness right \ --subscribe brightness brightness_change system_woke \ --set brightness \ + `# updates=on, NOT the config-wide when_shown default — the same one-way` \ + `# door proved on the volume item. brightness.sh sets drawing=off when it` \ + `# cannot read a level, and bar_item_update() gates TIMED runs as well as` \ + `# event ones behind` \ + `# should_update = updates_only_when_shown ? is_shown : true` \ + `# while bar_draw() clears the bar association of anything it does not` \ + `# draw. Under when_shown the 5s poll below would stop the instant the` \ + `# item hid, so it could never read its way back — permanently and` \ + `# silently gone until a config reload.` \ + updates=on \ update_freq=5 \ script="$PLUGIN_DIR/brightness.sh" @@ -586,8 +624,31 @@ sketchybar \ --add item volume right \ --subscribe volume volume_change \ --set volume \ - `# island.hardware's anchor — volume is the one member that survives` \ - `# battery and brightness both going away, so the pill never collapses.` \ + `# NO LONGER island.hardware's anchor. It used to be described as the one` \ + `# member that survives battery and brightness going away — but volume.sh` \ + `# hides this item whenever the output device has no software volume, and` \ + `# a class-compliant USB interface (the everyday case at this desk) is` \ + `# exactly that. With battery also hidden on AC, island.hardware is left` \ + `# carrying brightness alone. It cannot vanish — inset.hardware.l/.r never` \ + `# hide — so if brightness went too it would render as an empty 12pt pill` \ + `# rather than disappear. brightness reads a real value now (see its own` \ + `# note below), so that is a corner rather than the everyday case; if it` \ + `# ever shows up the fix is a regroup, not a fake number here.` \ + `#` \ + `# updates=on, NOT the config-wide when_shown default. This is the same` \ + `# trap thermals guards against, and it is worse here because this item` \ + `# hides itself as a matter of course rather than only on failure:` \ + `# bar_item_update() computes` \ + `# should_update = updates_only_when_shown ? is_shown : true` \ + `# and that gates EVENT-driven runs too, not just timed ones — while` \ + `# bar_draw() drops the bar association of anything it does not draw, so` \ + `# drawing=off makes is_shown false. Without this line, the moment` \ + `# volume.sh hid the item it could never run again to un-hide itself, and` \ + `# plugging in headphones would do nothing until a config reload.` \ + `# update_freq stays unset: with updates=on and update_frequency == 0 a` \ + `# timer tick still returns early (sender == NULL) while an event passes,` \ + `# so this buys back recoverability WITHOUT introducing polling.` \ + updates=on \ script="$PLUGIN_DIR/volume.sh" \ click_script="osascript -e 'set volume output muted not (output muted of (get volume settings))'" @@ -616,6 +677,8 @@ sketchybar \ --add item bluetooth right \ --subscribe bluetooth system_woke \ --set bluetooth \ + `# SELF-HIDING ITEM -> updates=on. See the volume item.` \ + updates=on \ update_freq=120 \ script="$PLUGIN_DIR/bluetooth.sh" \ click_script="open 'x-apple.systempreferences:com.apple.BluetoothSettings'" @@ -626,6 +689,8 @@ sketchybar \ --add item vpn right \ --subscribe vpn system_woke \ --set vpn \ + `# SELF-HIDING ITEM -> updates=on. See the volume item.` \ + updates=on \ update_freq=10 \ script="$PLUGIN_DIR/vpn.sh" \ click_script="open 'x-apple.systempreferences:com.apple.Network-Settings.extension'" @@ -644,11 +709,17 @@ sketchybar \ --set inset.status.r "${BLANK_STYLE[@]}" width=6 # Current conditions. 900s because weather does not change faster than that and -# every poll is a request to a third party — see the privacy note in weather.sh. +# every poll is an unauthenticated request to a third party that geolocates by +# source IP — see the PRIVACY note at the top of weather.sh, which also documents +# SKETCHYBAR_WEATHER_LOCATION for pinning a place or turning the item off. sketchybar \ --add item weather right \ --subscribe weather system_woke \ --set weather \ + `# SELF-HIDING ITEM -> updates=on. See the volume item. Worst case of` \ + `# the set: weather.sh hides on a FAILED FETCH, so one flaky request` \ + `# would otherwise retire this item permanently.` \ + updates=on \ update_freq=900 \ script="$PLUGIN_DIR/weather.sh" @@ -657,6 +728,8 @@ sketchybar \ sketchybar \ --add item github right \ --set github \ + `# SELF-HIDING ITEM -> updates=on. See the volume item.` \ + updates=on \ update_freq=300 \ script="$PLUGIN_DIR/github.sh" \ click_script="open 'https://github.com/notifications'" @@ -666,9 +739,44 @@ sketchybar \ sketchybar \ --add item mic right \ --set mic \ + `# SELF-HIDING ITEM -> updates=on. See the volume item for the full` \ + `# mechanism; the short version is that a hidden item under the` \ + `# config-wide when_shown default stops being polled, so mic.sh could` \ + `# never run again to notice that recording had started.` \ + updates=on \ update_freq=2 \ script="$PLUGIN_DIR/mic.sh" +# A macOS Focus-mode indicator (Do Not Disturb / Work / …) WAS BUILT HERE AND +# REMOVED. Recording it so it is not attempted a second time. +# +# The motivation was sound: the menu bar is auto-hidden, and the menu bar is the +# only place macOS shows the active Focus, so the state is invisible. The plugin +# worked — verified against synthetic fixtures for all five modes plus an unknown +# one. What killed it is that the only source of truth, +# ~/Library/DoNotDisturb/DB/Assertions.json +# is TCC-PROTECTED. Measured from a script sketchybar actually ran: +# PermissionError [Errno 1] Operation not permitted +# with the file at mode -rw-r--r-- and owned by the user — so it is macOS +# privacy, not Unix permissions. A shell in a terminal reads it fine because the +# terminal has Full Disk Access; sketchybar, launched by aerospace, does not. +# That difference is the whole trap: testing the read from a terminal proves +# nothing about whether the bar can do it. +# +# No unprotected route exists. Checked, all from a script sketchybar ran: +# ~/Library/DoNotDisturb/DB/Assertions.json BLOCKED +# defaults com.apple.donotdisturb no domain +# notificationcenterui doNotDisturb removed in Monterey +# com.apple.controlcenter readable, but its only Focus +# keys are widget position and +# visibility, not state +# ~/Library/Preferences/.GlobalPreferences readable (control: the block +# is specific, not blanket) +# +# The only fix is Full Disk Access on sketchybar, which was declined: it would +# extend FDA to every plugin script the bar spawns, and would likely need +# re-granting whenever `brew upgrade` replaces the binary. + # Amphetamine keep-awake state. Click toggles an indefinite session; the item is # subscribed to mouse.clicked rather than using click_script so the toggle and # the redraw live in one script. @@ -695,6 +803,53 @@ sketchybar \ --add item inset.status.l right \ --set inset.status.l "${BLANK_STYLE[@]}" width=6 +# SYSTEM ---------------------------------------------------- +# CPU load, memory, temperature and fan RPM. ADDED LAST, which is what puts it +# LEFTMOST on this side — see THE ADD ORDER IS THE LAYOUT above. +# +# It used to live on the left run and was moved here; with one screen there is +# room either way, so this is placement rather than necessity. +# +# Leftmost is the most STABLE position for it. Right-side items lay out from +# the bar's right edge leftward, so an island's width change shifts everything to +# its left and nothing to its right. This is the widest island here and the only +# one that changes width on its own (the fan RPM digit count), so putting it at +# the leading edge means an RPM change moves only its own left edge instead of +# dragging status and network with it. +# +# ONE SAMPLE, THREE ITEMS: a macmon sample costs ~0.9s, so only `thermals` runs +# system.sh; it writes cpu and memory too. cpu and memory are passive — no +# script, no update_freq — and would never update on their own. +sketchybar \ + --add item spacer.system right \ + --set spacer.system "${BLANK_STYLE[@]}" width=12 + +# Added thermals -> memory -> cpu so they READ cpu -> memory -> thermals. The +# background.padding values below are screen-space and do NOT swap with the add +# order: cpu is still the leftmost member and still carries the left interior +# padding. +sketchybar \ + --add item thermals right \ + --subscribe thermals system_woke \ + --set thermals \ + `# Rightmost member of island.system — carries its right interior padding.` \ + background.padding_right=6 \ + `# updates=on, NOT the config-wide when_shown default: system.sh hides` \ + `# all three items when macmon fails, and a hidden item under when_shown` \ + `# stops being polled — the failure would be permanent and silent.` \ + updates=on \ + update_freq=30 \ + script="$PLUGIN_DIR/system.sh" \ + click_script="open -a 'Macs Fan Control'" \ + --add item memory right \ + --add item cpu right \ + --set cpu \ + `# background.padding, NOT a pad item — island.system is one of the two` \ + `# islands that must be able to collapse, and a pad would never hide.` \ + `# Safe here because all three members hide together, so the ends can` \ + `# never drift. See ISLAND PADDING at the top.` \ + background.padding_left=6 + # --- Islands ----------------------------------------------- # Must come after every member item exists — a bracket can only reference items # that have already been added. @@ -711,30 +866,60 @@ sketchybar \ # nothing but are not hidden, so they are always counted and always contribute # their 6pt. That is what pins each island's interior at 12pt on both ends # regardless of which real members are showing — see ISLAND PADDING at the top. -# They must be named FIRST and LAST in each bracket; a pad in the middle would -# just add 6pt of dead space between two items. +# +# THE MEMBER LIST BELOW IS A SET, NOT AN ORDER. It is the pads' ADD order — hence +# their screen position — that has to put them at the two ends; where they appear +# in the bracket line is irrelevant. Read from src/group.c rather than assumed: +# group_get_first_member/group_get_last_member scan the members for minimum and +# maximum window origin.x, and group_get_length then adds first_item's +# padding_left to last_item's padding_right. Nothing consults the list order. +# (This is also why moving island.system to the right side needed its ADD order +# reversed but left its bracket line untouched.) The lists are still written in +# screen order, because that is how they read. # # corner_radius is half of height, which is what makes these pills rather than -# rounded rectangles. 26 centred in the 32pt bar puts the bottom edge at y=29 — -# see the gaps arithmetic at the top of this file. +# rounded rectangles. 32 centred in the 38pt bar puts the bottom edge at y=35 — +# see the gaps arithmetic at the top of this file. There is a hard ceiling here: +# bar_item_calculate_bounds caps an item at `bar height - (bar border_width + 1)` +# = 37, so an island taller than that is silently clipped rather than rejected. ISLAND_STYLE=( background.drawing=on - background.height=26 - background.corner_radius=13 + background.height=32 + background.corner_radius=16 background.color="$ISLAND" background.border_width=1 background.border_color="$ISLAND_BORDER" + # FROSTED PILLS. Note this is `blur_radius`, an ITEM property — NOT + # `background.blur_radius`, which does not exist. It works here because a + # bracket is itself an item and its window is the island's rect, so the blur + # lands on exactly the pill and the transparent bar behind it stays sharp. + # This is the alternative to a bar-level blur, which would frost the whole + # rectangle including the gaps between islands — tried, rejected. + # + # THE THING TO WATCH: the blur is applied to the item's WINDOW, which is + # rectangular, while the pill drawn in it is rounded. Whether the corners + # show a square frosted patch depends on macOS masking the backdrop by the + # window's alpha. Look at the corners of an island against a high-contrast + # wallpaper before assuming this is clean; blur_radius=0 reverts it with + # nothing else to change. + # + # Private API: reaches SLSSetWindowBackgroundBlurRadius via + # window_set_blur_radius (src/window.c). Undocumented SkyLight, so a + # plausible casualty of a macOS update — the failure mode is cosmetic. + blur_radius=30 ) sketchybar \ --add bracket island.spaces inset.spaces.l "${space_items[@]}" inset.spaces.r \ --set island.spaces "${ISLAND_STYLE[@]}" \ \ - `# island.app is disabled — see the front_app block above. A bracket naming` \ - `# an item that does not exist only WARNS, it does not fail, so leaving this` \ - `# in would have cost nothing visible and hidden the mistake.` \ - `# --add bracket island.app front_app` \ - `# --set island.app "${ISLAND_STYLE[@]}"` \ + `# Sole member, so its interior comes from background.padding on front_app` \ + `# rather than pad items — safe because this island cannot collapse. See the` \ + `# front_app block above. If it is ever disabled again, comment THIS OUT TOO:` \ + `# a bracket naming an item that does not exist only WARNS, it does not fail,` \ + `# so a stale line here costs nothing visible and hides the mistake.` \ + --add bracket island.app front_app \ + --set island.app "${ISLAND_STYLE[@]}" \ \ `# THE TWO COLLAPSING ISLANDS. No pads here, deliberately: every member of` \ `# each hides at once — system.sh when macmon fails, music.sh when nothing` \ From 7efce1c339426d98d156bc3084159a83d6a3862e Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:45:54 +0200 Subject: [PATCH 04/13] aerospace: float Raycast; the "not installed" comment had gone stale The rule was commented out on the belief that Raycast was in the Brewfile but not installed. /Applications/Raycast.app exists and its Info.plist gives com.raycast.macos -- exactly the id the comment declined to guess. Caveat recorded inline: the id came from Info.plist, not from `aerospace list-apps` with the Settings window open, since Raycast was not running. --- aerospace/.config/aerospace/aerospace.toml | 262 ++++++++++++--------- 1 file changed, 157 insertions(+), 105 deletions(-) diff --git a/aerospace/.config/aerospace/aerospace.toml b/aerospace/.config/aerospace/aerospace.toml index c7ff264..176cec0 100644 --- a/aerospace/.config/aerospace/aerospace.toml +++ b/aerospace/.config/aerospace/aerospace.toml @@ -14,18 +14,81 @@ # ^[d (kill-word), ^[m, ^[n, ^[p, ^[g, ^[a. So: numeric workspaces only. # Before adding any alt- binding here, check it against `bindkey -M emacs`. # +# TWO SEPARATE HAZARDS LIVE ON alt, AND THEY ARE NOT THE SAME PROBLEM: +# +# alt- collides with ZSH WIDGETS (the paragraph above) +# alt- collides with the KEYBOARD LAYOUT (this paragraph) +# +# This machine is on the DANISH layout, where brackets and braces are on the +# Option layer of the digit row. Binding alt- here makes them untypable +# system-wide, with no alternative route on the layout: +# alt-8 [ alt-9 ] alt-shift-7 \ alt-shift-8 { alt-shift-9 } +# That is why workspace switching is on CTRL-, not alt-. It is not +# a style choice and it must not be "tidied" back to alt to match upstream. +# +# CTRL is the escape and CMD is not. Measured with UCKeyTranslate against the +# live input source, key 8: +# alt-8 -> [ alt-shift-8 -> { +# cmd-alt-8 -> [ <- Command does NOT suppress the Option layer +# ctrl-8 -> 8 ctrl-shift-8 -> 8 <- Control does +# ctrl-alt-8 -> 8 ctrl-alt-shift-8 -> 8 +# Same applies to Norwegian, Swedish, Finnish and German layouts. If this config +# is ever used on US ANSI the constraint disappears, but the bindings can stay. +# +# ctrl- and ctrl-shift- were checked and are owned by nothing here: +# not kitty.conf, not this file, not `bindkey -M emacs`. +# +# PUT CONTROL ON CAPS LOCK. Reaching the bottom-left Ctrl for a key pressed this +# often is the same stretch that ruled out alt-ctrl; Caps Lock is the best-placed +# key on the board and macOS remaps it natively, no software required: +# System Settings > Keyboard > Keyboard Shortcuts… > Modifier Keys +# +# THAT PANEL IS PER-KEYBOARD, which is the trap. macOS stores the mapping as +# `com.apple.keyboard.modifiermapping.--0`, so setting it on one +# device does nothing for another and the panel has a keyboard selector that is +# easy to miss. Two are attached here (from IOHIDManager): +# Keychron Link vendor 13364 product 53296 +# USB Receiver vendor 1133 product 50504 (Logitech) +# "I set it and it didn't work" almost always means it was set on the other one. +# +# IF THE NATIVE REMAP FEELS LAGGY, that is its known Caps Lock debounce, and it +# is the only good reason to escalate. Two escalations, in order of preference: +# 1. THE KEYBOARD'S OWN FIRMWARE. Many Keychron boards are QMK/VIA +# programmable and QMK has a native Hyper (LCTL+LSFT+LALT+LGUI). No Mac-side +# software, no driver, no permission grant, and it travels with the keyboard. +# Check the model against Keychron's VIA/Launcher support first. +# 2. Karabiner-Elements. Buys hyper (so ctrl+digit returns to apps) and no +# activation delay, at the cost of a DriverKit virtual HID driver, an Input +# Monitoring grant, and a karabiner.json that the app rewrites — awkward to +# stow for the same reason ~/.claude and ~/.ssh are (see CLAUDE.md). If it +# comes to that, stow a rule file under assets/complex_modifications/, which +# the app treats as read-only input, not karabiner.json itself. +# +# ONE THING TO WATCH: macOS Mission Control has "Switch to Desktop N" on ctrl-1..9 +# by default whenever more than one Space exists. System hotkeys are processed +# early and can beat AeroSpace, so keep to a single macOS Space or uncheck those +# in System Settings > Keyboard > Keyboard Shortcuts > Mission Control. +# # Conscious losses to the shell, all low-value: # ^[h run-help -> alt-h is 'focus left' # ^[l OMZ ls widget -> alt-l is 'focus right' -# ^[1..^[9 digit-argument -> workspace switching +# ^[F forward-word -> alt-shift-f is 'fullscreen' (^[f is the same widget) +# ^[1..^[9 (digit-argument) and ^[^H / ^[^L (backward-kill-word, clear-screen) +# used to be on that list and are RECOVERED — nothing binds alt- any more, +# and the alt-ctrl-shift-h/l monitor bindings went with the second monitor. # -# alt-ctrl-shift- reaches the terminal as ^[^ (Shift is dropped -# for control characters), costing two more. Both keep an alternative: -# ^[^H backward-kill-word -> alt-ctrl-shift-h throws a window one monitor left -# (alt-backspace, ^[^?, still does it) -# ^[^L clear-screen -> alt-ctrl-shift-l throws a window one monitor right -# (ctrl-l still does it) -# ^[F forward-word -> alt-shift-f is 'fullscreen' (^[f is the same widget) +# INSIDE KITTY THE TWO OPTION KEYS DIFFER, and that is deliberate — kitty.conf +# sets `macos_option_as_alt left`. So: +# LEFT option + 8 -> ^[8, i.e. digit-argument (kitty sends it as Alt) +# RIGHT option + 8 -> [ (kitty lets the layout compose it) +# Use the RIGHT Option key for brackets and braces in the terminal. Everywhere +# else there is only one Option layer and either key composes them. +# +# SINGLE SCREEN. This config is tuned for one 2560x1440 external and nothing +# else: no per-monitor gaps, no workspace-to-monitor assignment, no +# focus-monitor or move-node-to-monitor bindings, and no notch arithmetic. If a +# second display or the laptop's own screen ever comes back, all of that has to +# be reintroduced — the measurements are in git history, not here. # # ON ANIMATIONS: there are none, and none are possible. AeroSpace repositions a # window with a single Accessibility API write — there is no tween and no config @@ -77,24 +140,16 @@ gaps.inner.horizontal = 8 gaps.inner.vertical = 8 gaps.outer.left = 8 gaps.outer.bottom = 8 -# Per-monitor, because the built-in display has a notch and the externals don't. -# -# SketchyBar's islands are 26pt tall centred in a 32pt transparent bar, so their -# bottom edge is at y=29; 37 = 29 + the usual 8pt gap. The bar draws inside the -# area AeroSpace tiles into, so without this windows slide underneath it. -# -# The built-in gets 5, not 37. AeroSpace tiles inside the monitor's VISIBLE rect, -# and macOS reserves that display's top 32pt as notch safe area even with the -# menu bar auto-hidden (measured: safeAreaInsets.top = 32, visibleFrame 1512x950 -# of a 1512x982 frame). So the 32 is already excluded and only the remaining 5 -# has to be asked for. Measured to confirm: with a flat 34 here, a window on the -# built-in landed at y=66 (= 32 + 34) while one on an external landed at y=34 — -# a 32pt dead strip on the laptop only. 5 + 32 = 37 puts both at the same place. +# 43 CLEARS SKETCHYBAR, and the two must move together. The islands are 32pt +# tall centred in a 38pt bar, so their bottom edge is at y=35; 43 = 35 + the +# usual 8pt gap. The bar draws inside the area AeroSpace tiles into, so without +# this windows slide underneath it. Retune the bar height or the island height +# and this number is wrong. # # Also paired with the macOS menu bar being auto-hidden (see the install script): # with the menu bar visible this would sit on top of its 30, and the bar would # land below it rather than at the top of the screen. -gaps.outer.top = [{ monitor.'built-in' = 5 }, 37] +gaps.outer.top = 43 gaps.outer.right = 8 @@ -110,13 +165,12 @@ gaps.outer.right = 8 # If a held-down alt-h/alt-l ever makes the pulse look like flicker, drop this # callback — the static ring survives on its own. # -# Deliberately NOT 'move-mouse window-lazy-center' here: the pointer stays put -# on window focus changes. The monitor callback below is a different thing — it -# only fires when the focused *monitor* changes. +# Deliberately NOT 'move-mouse window-lazy-center': the pointer stays put on +# focus changes. (There was an on-focused-monitor-changed callback here too; it +# can never fire with one screen.) on-focus-changed = [ 'exec-and-forget /bin/sh -c "borders active_color=0xffbabbf1; sleep 0.12; borders active_color=0xffca9ee6"', ] -on-focused-monitor-changed = ['move-mouse monitor-lazy-center'] # Pushes workspace switches to SketchyBar, which has no way to learn about them # on its own. Inert while sketchybar isn't running — the command just fails into @@ -163,13 +217,36 @@ on-window-detected = [ { if = 'test %{app-bundle-id} = com.titanium.OnyX', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.jetbrains.toolbox', run = 'layout floating' }, # Raycast — the launcher panel itself is a non-activating window AeroSpace - # already ignores, but its Settings window tiles. Left COMMENTED because the - # bundle ID is unverified: Raycast is in the Brewfile but not installed here, - # and the Homebrew cask carries no zap/quit stanza to read it from. Per the - # rule above, read it rather than guess — install Raycast, open its Settings - # window, run `aerospace list-apps`, paste what it reports, and uncomment. - # A wrong ID does not error; the rule just never fires. - # { if = 'test %{app-bundle-id} = com.raycast.macos', run = 'layout floating' }, + # already ignores, but its Settings window tiles. This was commented out for + # a long time on the belief that Raycast "is in the Brewfile but not + # installed here"; that had become false — /Applications/Raycast.app exists + # and its Info.plist gives the id below. Enabled. + # Caveat, same as the two rules further down: the id came from Info.plist, + # not from `aerospace list-apps` with the Settings window open, because + # Raycast was not running. If the rule never fires, that is the first thing + # to re-check — a wrong id does not error, it just never matches. + { if = 'test %{app-bundle-id} = com.raycast.macos', run = 'layout floating' }, + + # Float — games and compatibility layers. Different reason from the block + # above: these aren't utilities, they just tile badly. Game windows expect to + # own their geometry, and a tiling WM resizing them mid-session ranges from + # letterboxing to a wedged renderer. + { if = 'test %{app-bundle-id} = com.codeweavers.CrossOver', run = 'layout floating' }, + { if = 'test %{app-bundle-id} = com.valvesoftware.steam', run = 'layout floating' }, + # + # THESE TWO COVER THE LAUNCHERS, NOT THE GAMES. CrossOver is at + # ~/Applications/CrossOver.app (not /Applications), and every bottle gets its + # own generated launcher with a HASHED bundle id, e.g. measured here: + # ~/Applications/CrossOver/Steam/Steam.app + # com.codeweavers.CrossOverHelper.4DB4563826BAD0EB2F60EE6E42D0EA4B.D7B4… + # ~/Applications/CrossOver/Battle.net/Battle.net.app + # com.codeweavers.CrossOverHelper.747D99F92EE9C080BA26108AC5D26488.4C9D… + # So a window from a bottle does NOT match the CrossOver rule above, and + # pasting the hashes in would break the next time a bottle is created. If + # bottle windows need floating, run `aerospace list-apps` WITH THE GAME OPEN + # and add what it actually reports — per the "read it, don't guess" rule + # above. Both ids here came from each app's Info.plist; neither app was + # running to confirm against list-apps. # Place — chat/mail/notes open on their home screens (see the map below). { if = 'test %{app-bundle-id} = com.microsoft.teams2', run = 'move-node-to-workspace 6' }, @@ -177,42 +254,11 @@ on-window-detected = [ { if = 'test %{app-bundle-id} = md.obsidian', run = 'move-node-to-workspace 8' }, ] - -# ============================================================ -# Monitors -# ============================================================ -# The desk, left to right — AeroSpace ordinals are ordered the same way, so its -# numbering already matches the physical layout: -# 1 = Built-in Retina Display (left, laptop) -# 2 = P24h-2L (1) (middle, macOS main display — the work screen) -# 3 = P24h-2L (2) (right, side monitor) -# -# Both externals report the same name ("P24h-2L"); macOS appends (1)/(2) and -# those suffixes can swap on reconnect, so they are never matched by name. -# -# The low numbers go to the middle screen deliberately: easiest keys on the -# busiest monitor, at the cost of alt-N no longer tracking desk position. -# -# LAPTOP MAIN (middle) SIDE -# ┌───────┐ ┌─────────────────┐ ┌───────┐ -# │ 8 9 │ │ 1 2 3 4 5 │ │ 6 7 │ -# └───────┘ └─────────────────┘ └───────┘ -# -# Every entry ends in a pattern that always resolves, so undocking or closing -# the lid collapses workspaces onto a live screen instead of stranding them. -# -# NOTE: force-assignment makes `move-workspace-to-monitor` a no-op. Use -# `move-node-to-monitor` (alt-ctrl-shift-h/l below) to shuffle windows instead. -[workspace-to-monitor-force-assignment] - 1 = 'main' # middle — the work screen - 2 = 'main' - 3 = 'main' - 4 = 'main' - 5 = 'main' - 6 = ['3', 'secondary'] # right — ordinal with 3 screens, secondary with 2 - 7 = ['3', 'secondary'] - 8 = ['built-in', 'main'] # laptop — 'main' covers clamshell - 9 = ['built-in', 'main'] +# There is no [workspace-to-monitor-force-assignment] section, deliberately — +# with one screen every workspace lands there anyway. The app-placement rules +# above still hold: Teams/Outlook/Obsidian open on workspaces 6/7/8 so they are +# out of the way, which is now about workspace hygiene rather than which screen +# they appear on. # ============================================================ @@ -229,18 +275,22 @@ on-window-detected = [ # remote-control setup here for a new window to attach to. alt-shift-enter = 'exec-and-forget open -na kitty' - # Focus — the three monitors sit side by side, so h/l treat them as one - # continuous frame and walk across the edges. j/k stay workspace-local. - alt-h = 'focus --boundaries all-monitors-outer-frame left' - alt-j = 'focus down' - alt-k = 'focus up' - alt-l = 'focus --boundaries all-monitors-outer-frame right' - - # Move the focused window — h/l carry it onto the neighbouring monitor - alt-shift-h = 'move --boundaries all-monitors-outer-frame left' + # Focus. --wrap-around because on one screen the alternative is a dead key: + # with two windows side by side, alt-l from the right-hand one used to do + # nothing at all, which reads as broken rather than as "no window there". + # Wrapping makes every press move focus somewhere. + alt-h = 'focus --wrap-around left' + alt-j = 'focus --wrap-around down' + alt-k = 'focus --wrap-around up' + alt-l = 'focus --wrap-around right' + + # Move the focused window. NOT --wrap-around, deliberately: wrapping a MOVE + # reorders the tree and is awkward to undo, whereas a focus that wraps costs + # nothing. A move that stops at the edge is the safe default. + alt-shift-h = 'move left' alt-shift-j = 'move down' alt-shift-k = 'move up' - alt-shift-l = 'move --boundaries all-monitors-outer-frame right' + alt-shift-l = 'move right' # Resize alt-minus = 'resize smart -50' @@ -252,37 +302,39 @@ on-window-detected = [ # ^[F is bound to forward-word, but so is ^[f — this costs a duplicate. alt-shift-f = 'fullscreen' - # Workspaces - alt-1 = 'workspace 1' - alt-2 = 'workspace 2' - alt-3 = 'workspace 3' - alt-4 = 'workspace 4' - alt-5 = 'workspace 5' - alt-6 = 'workspace 6' - alt-7 = 'workspace 7' - alt-8 = 'workspace 8' - alt-9 = 'workspace 9' - - # Send the focused window to a workspace - alt-shift-1 = 'move-node-to-workspace 1' - alt-shift-2 = 'move-node-to-workspace 2' - alt-shift-3 = 'move-node-to-workspace 3' - alt-shift-4 = 'move-node-to-workspace 4' - alt-shift-5 = 'move-node-to-workspace 5' - alt-shift-6 = 'move-node-to-workspace 6' - alt-shift-7 = 'move-node-to-workspace 7' - alt-shift-8 = 'move-node-to-workspace 8' - alt-shift-9 = 'move-node-to-workspace 9' + # Workspaces. CTRL, NOT ALT — alt- is where the Danish layout keeps + # [ ] { } \ and binding it here makes them untypable system-wide. See the + # two-hazards note at the top of this file before changing this. + ctrl-1 = 'workspace 1' + ctrl-2 = 'workspace 2' + ctrl-3 = 'workspace 3' + ctrl-4 = 'workspace 4' + ctrl-5 = 'workspace 5' + ctrl-6 = 'workspace 6' + ctrl-7 = 'workspace 7' + ctrl-8 = 'workspace 8' + ctrl-9 = 'workspace 9' + + # Send the focused window to a workspace. ctrl-shift, matching the switch + # bindings above — alt-shift-7/8/9 are \ { } on this layout. + ctrl-shift-1 = 'move-node-to-workspace 1' + ctrl-shift-2 = 'move-node-to-workspace 2' + ctrl-shift-3 = 'move-node-to-workspace 3' + ctrl-shift-4 = 'move-node-to-workspace 4' + ctrl-shift-5 = 'move-node-to-workspace 5' + ctrl-shift-6 = 'move-node-to-workspace 6' + ctrl-shift-7 = 'move-node-to-workspace 7' + ctrl-shift-8 = 'move-node-to-workspace 8' + ctrl-shift-9 = 'move-node-to-workspace 9' alt-tab = 'workspace-back-and-forth' # Window-level counterpart to alt-tab. ^[` is unbound in emacs mode. alt-backtick = 'focus-back-and-forth' - # Was move-workspace-to-monitor, which force-assignment turns into a no-op. - alt-shift-tab = 'focus-monitor --wrap-around next' - # Throw the focused window one screen over and follow it there - alt-ctrl-shift-h = 'move-node-to-monitor --focus-follows-window left' - alt-ctrl-shift-l = 'move-node-to-monitor --focus-follows-window right' + # alt-shift-tab (focus-monitor) and alt-ctrl-shift-h/l (move-node-to-monitor) + # lived here and are gone with the second screen. That hands ^[^H + # (backward-kill-word) and ^[^L (clear-screen) back to the shell, and leaves + # three easy chords free if something else wants them. alt-shift-semicolon = 'mode service' From 3255c31858ad29f962dc2662fa9057fd3c178c10 Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:46:18 +0200 Subject: [PATCH 05/13] sketchybar: fix volume and brightness reporting values they cannot know volume showed "missing value%". osascript prints that literal string and exits 0 for a device with no software volume, so the `[ -z "$vol" ]` guard sailed past it. The output here is a Focusrite Scarlett 2i2, which exposes neither kAudioDevicePropertyVolumeScalar nor Mute on any scope or element -- confirmed against CoreAudio. There is genuinely no level to show. brightness showed a constant 100%. It looped display ids 1..16 and took the first DisplayServicesGetBrightness that answered; on this clamshell desk that is the external, and the call SUCCEEDS for a non-Apple external and returns a hardcoded 1.0. The 100% was fabricated, not stale. Both now read the right source (brightness: gamma ramp for an external, which is what MonitorControl's software dimming manipulates -- verified 0.75/0.50/0.25 -> 75/50/25%) and hide rather than invent a number. colors.sh gains the compositing note left over from a frosted-bar experiment that was reverted. --- sketchybar/.config/sketchybar/colors.sh | 22 +++-- .../.config/sketchybar/plugins/brightness.sh | 89 +++++++++++++++++-- .../.config/sketchybar/plugins/volume.sh | 70 ++++++++++++--- 3 files changed, 155 insertions(+), 26 deletions(-) diff --git a/sketchybar/.config/sketchybar/colors.sh b/sketchybar/.config/sketchybar/colors.sh index 593a409..0715ef6 100755 --- a/sketchybar/.config/sketchybar/colors.sh +++ b/sketchybar/.config/sketchybar/colors.sh @@ -27,12 +27,24 @@ export TRANSPARENT=0x00000000 # sketchybarrc) — these pills are the only thing drawn, so their alpha byte is # the single knob for how see-through the whole bar looks. # 0xe6 90% 0xcc 80% 0xb3 70% (current) 0x99 60% 0x80 50% -# Below about 60% the Catppuccin text starts losing contrast on a busy wallpaper. -# There is no blur to fall back on: blur_radius is a BAR-level property and there -# is no background.blur_radius, so enabling it would frost the entire bar -# rectangle including the gaps between islands — a blurred band across the whole -# screen top, which is exactly the effect the islands exist to avoid. +# Below about 60% the Catppuccin text starts losing contrast on a busy wallpaper — +# though the pills are frosted now (see ISLAND_STYLE in sketchybarrc), and blur +# buys back some of that headroom by killing the detail behind the text. +# +# BLUR IS PER-ITEM, NOT BAR-ONLY. An older version of this note said blur was a +# bar property with "no background.blur_radius", and concluded that using it +# would frost the whole bar rectangle including the gaps — a band across the +# screen top, which is exactly what the islands exist to avoid. Half right: +# - true: there is no background.blur_radius, so blur is not something a pill +# inherits from its background the way it inherits colour. +# - false: it is not bar-only. blur_radius is also an ITEM property +# (PROPERTY_BLUR_RADIUS, src/bar_item.c), applied to that item's own +# window — and a bracket IS an item whose window is the island rect. +# So the pills can be frosted individually while the bar stays transparent, which +# is what this config does. A full-width frosted band was tried and rejected. export ISLAND=0xb3232634 # Kept fully opaque on purpose: as the fill gets more transparent the border is # what keeps the pill's edge crisp and its shape readable against the desktop. +# It matters more with blur, not less — a frosted fill has softer edges than a +# flat one, so the border is what stops the pill dissolving into the wallpaper. export ISLAND_BORDER=0xff414559 diff --git a/sketchybar/.config/sketchybar/plugins/brightness.sh b/sketchybar/.config/sketchybar/plugins/brightness.sh index f6c395e..32a884d 100755 --- a/sketchybar/.config/sketchybar/plugins/brightness.sh +++ b/sketchybar/.config/sketchybar/plugins/brightness.sh @@ -1,5 +1,40 @@ #!/usr/bin/env bash -# Display brightness for the BUILT-IN screen. +# Display brightness, for whichever display is MAIN. +# +# TWO DISPLAYS, TWO MECHANISMS, and reading the wrong one is how this item spent +# a long time confidently showing 100%: +# +# BUILT-IN has a real backlight that macOS owns, so DisplayServicesGetBrightness +# reports it truthfully. +# +# EXTERNAL does not. DisplayServicesGetBrightness SUCCEEDS on a non-Apple +# external and returns a hardcoded 1.0 — measured on the Samsung +# here (CGDirectDisplayID 2, builtin=false). It is not stale, it is +# FABRICATED, which is worse: nothing errors and the number looks +# plausible. The old version of this script looped display IDs 1..16 +# and took the first call that succeeded, which on a clamshell desk +# is exactly that lie. +# +# So for an external we read what the DIMMER ACTUALLY DID rather than asking +# macOS. MonitorControl is in software-dimming mode for this display +# (`forceSw(...)=1`, `avoidGamma=0` in app.monitorcontrol.MonitorControl), which +# works by scaling the display's GAMMA RAMP — confirmed by `nm -u` on its binary, +# which imports both _CGSetDisplayTransferByTable and _CGGetDisplayTransferByTable. +# We read the ramp back through the same public API it writes with. +# +# WHY NOT DDC (m1ddc, ddcctl): in software mode MonitorControl never touches the +# monitor's internal DDC brightness, so a DDC read returns an unrelated number +# that happens to look reasonable. It would also cost a ~100-300ms subprocess on +# every poll and put traffic on a bus MonitorControl is already using. +# WHY NOT ASK MonitorControl: it has no .sdef, no NSAppleScriptEnabled, no +# CFBundleURLTypes and no bundled CLI. There is nothing to ask. +# Its `SwBrightness(@)` pref does hold the value, +# but that is a private, unversioned key layout — the gamma ramp is the effect +# itself and is public API. +# +# LIMIT: if MonitorControl is switched to hardware/DDC dimming, the ramp stays +# flat and this reports 100% at every real brightness. That is a knowing trade — +# it is no worse than the behaviour this replaced. source "$HOME/.config/sketchybar/colors.sh" @@ -9,16 +44,52 @@ else pct="$(python3 -c ' import ctypes, sys -ds = ctypes.CDLL("/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices") -ds.DisplayServicesGetBrightness.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_float)] -ds.DisplayServicesGetBrightness.restype = ctypes.c_int +cg = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics") +cg.CGMainDisplayID.restype = ctypes.c_uint32 +cg.CGDisplayIsBuiltin.argtypes = [ctypes.c_uint32] +cg.CGDisplayIsBuiltin.restype = ctypes.c_uint32 -for d in range(1, 17): +did = cg.CGMainDisplayID() + +if cg.CGDisplayIsBuiltin(did): + ds = ctypes.CDLL("/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices") + ds.DisplayServicesGetBrightness.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_float)] + ds.DisplayServicesGetBrightness.restype = ctypes.c_int b = ctypes.c_float() - if ds.DisplayServicesGetBrightness(ctypes.c_uint32(d), ctypes.byref(b)) == 0: - print(round(b.value * 100)) - sys.exit() -sys.exit(1) + if ds.DisplayServicesGetBrightness(ctypes.c_uint32(did), ctypes.byref(b)) != 0: + sys.exit(1) + print(round(b.value * 100)) + sys.exit() + +cg.CGDisplayGammaTableCapacity.argtypes = [ctypes.c_uint32] +cg.CGDisplayGammaTableCapacity.restype = ctypes.c_uint32 +cg.CGGetDisplayTransferByTable.argtypes = [ + ctypes.c_uint32, ctypes.c_uint32, + ctypes.POINTER(ctypes.c_float), ctypes.POINTER(ctypes.c_float), + ctypes.POINTER(ctypes.c_float), ctypes.POINTER(ctypes.c_uint32), +] +cg.CGGetDisplayTransferByTable.restype = ctypes.c_int + +cap = cg.CGDisplayGammaTableCapacity(did) +if cap == 0: + sys.exit(1) + +Table = ctypes.c_float * cap +r, g, b = Table(), Table(), Table() +n = ctypes.c_uint32() +if cg.CGGetDisplayTransferByTable(did, cap, r, g, b, ctypes.byref(n)) != 0 or n.value == 0: + sys.exit(1) + +# RED, not the max across channels. Colour-temperature shifters (Night Shift, +# f.lux) rewrite this same ramp, but they scale BLUE and some green while +# leaving red near 1.0; brightness dimming scales all three uniformly. Reading +# red therefore reports brightness alone and ignores the colour shift. +# +# The ramp is monotonic, so its last entry is its maximum — the scale factor the +# dimmer applied. A FLAT ramp (1.0) means no dimming, i.e. a genuine 100%, and +# must still be reported; exiting non-zero is reserved for a failed call, which +# is the only case where the level is truly unknown. +print(round(r[n.value - 1] * 100)) ' 2>/dev/null)" fi diff --git a/sketchybar/.config/sketchybar/plugins/volume.sh b/sketchybar/.config/sketchybar/plugins/volume.sh index 5600c87..7d505ed 100755 --- a/sketchybar/.config/sketchybar/plugins/volume.sh +++ b/sketchybar/.config/sketchybar/plugins/volume.sh @@ -1,25 +1,71 @@ #!/usr/bin/env bash # Volume. Driven by sketchybar's native volume_change event ($INFO = the new # percentage), so there is no polling. +# +# NOT EVERY OUTPUT DEVICE HAS A VOLUME. Class-compliant USB interfaces set their +# level in hardware and expose no software control at all — measured on a +# Focusrite Scarlett 2i2 4th Gen, which has neither kAudioDevicePropertyVolumeScalar +# nor kAudioDevicePropertyMute on any scope or element. macOS greys its own slider +# out for these. That is a NORMAL state for such a device, not a failure, and this +# script's job is then to show nothing rather than to invent a number. source "$HOME/.config/sketchybar/colors.sh" -if [ "$SENDER" = "volume_change" ]; then - vol="$INFO" -else - vol="$(osascript -e 'output volume of (get volume settings)' 2>/dev/null)" -fi +# ONE osascript call for both readings. This used to be two — one for the level +# and one for the mute state — which is how the bug below survived: the two code +# paths disagreed about what an unreadable device looked like. +settings="$(osascript -e 'get volume settings' 2>/dev/null)" -muted="$(osascript -e 'output muted of (get volume settings)' 2>/dev/null)" +# "output volume:50, input volume:100, alert volume:100, output muted:false" +# Parameter expansion rather than sed/awk, to keep this to a single subprocess. +# `#*output volume:` is safe despite "alert volume" also containing "volume" — +# it matches the shortest prefix, and "output volume" is the first field. +vol="${settings#*output volume:}" +vol="${vol%%,*}" +muted="${settings##*output muted:}" -# An unreadable volume is not a volume of zero. Kept separate from the branch -# below so the bar never states a level it does not actually know — the same rule -# thermals follows in system.sh. -if [ -z "$vol" ]; then - sketchybar --set "$NAME" drawing=off - exit 0 +# THE READABILITY TEST, AND IT RUNS BEFORE $SENDER IS CONSULTED. Two traps, both +# measured rather than guessed: +# +# 1. osascript prints the literal string "missing value" and EXITS 0 for a +# device with no software volume. So the value is not empty, and the obvious +# `[ -z "$vol" ]` guard sails straight past it — which is exactly how this +# item came to render "missing value%" on the bar. +# +# 2. $INFO CANNOT BE TRUSTED HERE EITHER, so this test must not be skipped on +# the volume_change path. sketchybar's own handler (src/volume.c) declares +# `float volume_main = 0.f`, ignores the return of AudioObjectGetPropertyData, +# and posts the untouched 0 when the read fails. device_changed() calls that +# handler on every default-output-device switch, so switching TO such a +# device delivers $INFO=0 — which would render as a confident "0%". That is +# worse than the visible garbage it replaced, because it looks plausible. +# +# An unreadable volume is not a volume of zero. The bar never states a level it +# does not actually know — the same rule thermals follows in system.sh. +case "$vol" in + '' | *'missing value'*) + # See `updates=on` on this item in sketchybarrc: under the config-wide + # when_shown default a hidden item stops receiving its subscribed events, + # so this line would be a ONE-WAY DOOR and plugging in headphones later + # could never bring the item back. + sketchybar --set "$NAME" drawing=off + exit 0 + ;; +esac + +# Only now is the fast path safe: the device is known to have a readable volume, +# so a volume_change event's $INFO is a real percentage and is fresher than the +# reading above. +if [ "$SENDER" = "volume_change" ] && [ -n "$INFO" ]; then + vol="$INFO" fi +# A device can report a level but no mute state; treat that as not muted rather +# than letting "missing value" fall through the string comparison by accident. +case "$muted" in + *'missing value'*) muted="false" ;; +esac + # Muted reads 0%, not "muted": it is the same quantity as every other state of # this item rather than a different kind of thing, so it lines up with the # neighbouring percentages instead of making the pill jump width. The struck-out From 20a5f400e9a8ff691139fe46f3aa754c4e917740 Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:46:18 +0200 Subject: [PATCH 06/13] docs: record the silent-failure rules, and add the production audit Three failure classes that are invisible when they occur, all found the hard way: - a self-hiding item under the config-wide updates=when_shown default stops being updated entirely, so it can never un-hide. Eight items were affected; each worked after a reload and then went quiet. - SketchyBar fork_execs plugins and reports NOTHING when the execute bit is missing. The item simply never updates. - a plugin runs with less TCC access than a terminal, so ~/Library reads succeed by hand and fail with EPERM in the bar, at correct Unix permissions. docs/audit-2026-08-02.md is a full production-readiness review with 20 findings, measured costs per plugin, and a four-phase roadmap. --- CLAUDE.md | 12 +++++++++++- README.md | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 500d613..a2f452b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,12 +38,22 @@ Create a directory whose internal structure mirrors the home-relative path (e.g. - **Kitty theme** is extracted to `kitty/.config/kitty/themes/catppuccin-frappe.conf` via `include` — edit the theme file, not `kitty.conf`. - **Git pager** is `delta` (not `less`). The `[delta]` section in `.gitconfig` is the single source of truth — lazygit invokes bare `delta`, which reads that section itself. lazygit's key is `git.pagers[].pager` (an *array* since 0.55; `git.paging` is the legacy form it migrates from). A bare `git.pager` is silently ignored, with no warning — verify changes actually took effect rather than trusting the config to be read. - **SSH config is first-match-wins.** `Host *` must stay at the bottom of `ssh/.ssh/config`; a keyword set there can never be overridden by a `Host` block below it. `Include ~/.ssh/config.local` stays at the very top so machine-local overrides win (a missing include file is not an error). -- **AeroSpace binds keys system-wide**, ahead of kitty and zsh. Its config deliberately uses *numeric* workspaces only: upstream's `default-config.toml` binds `alt-` for 26 letter-named workspaces, which would swallow `^[c` (fzf-cd-widget), `^[b`/`^[f` (word motion), `^[d` (kill-word) and more. Before adding any `alt-` binding — in `aerospace.toml`, `kitty.conf`, or `.zshrc` — check it against `bindkey -M emacs` first. Config lives at `aerospace/.config/aerospace/aerospace.toml`; a stray `~/.aerospace.toml` makes AeroSpace error on the ambiguity. +- **AeroSpace binds keys system-wide**, ahead of kitty and zsh, and `alt` carries **two independent hazards** that are easy to conflate: + - **`alt-` collides with zsh widgets.** Upstream's `default-config.toml` binds `alt-` for 26 letter-named workspaces, which would swallow `^[c` (fzf-cd-widget), `^[b`/`^[f` (word motion), `^[d` (kill-word) and more. Hence *numeric* workspaces only. Before adding any `alt-` binding — in `aerospace.toml`, `kitty.conf`, or `.zshrc` — check it against `bindkey -M emacs` first. + - **`alt-` collides with the keyboard layout.** This machine is Danish, where `[ ] { } \` live on the Option layer of the digit row (`alt-8`, `alt-9`, `alt-shift-7/8/9`) with no other route. Binding those makes them untypable system-wide. **Workspaces are therefore on `ctrl-`, not `alt-`** — do not "tidy" them back toward upstream. `ctrl` is the escape and `cmd` is not: measured with `UCKeyTranslate`, `ctrl-8` → `8` but `cmd-alt-8` → `[`. Same applies to Norwegian/Swedish/Finnish/German layouts. + + Config lives at `aerospace/.config/aerospace/aerospace.toml`; a stray `~/.aerospace.toml` makes AeroSpace error on the ambiguity. +- **This machine is a SINGLE 2560×1440 external, and both AeroSpace and SketchyBar are tuned for exactly that.** There is no per-monitor `gaps`, no `workspace-to-monitor-force-assignment`, no `focus-monitor`/`move-node-to-monitor` binding, and no notch arithmetic or width budget in `sketchybarrc`. All of it existed for a laptop-plus-two-externals setup and was deliberately removed; the measurements are in git history. Don't reintroduce multi-monitor or notch assumptions without a second display actually being present. Note `kitty.conf` sets `macos_option_as_alt left`, so inside kitty the **left** Option key sends `ESC`-prefixed sequences while the **right** one composes layout characters — brackets in the terminal need the right Option key. - **`.zprofile` opens with `typeset -U path PATH`, and that line is load-bearing.** This file appends to PATH unconditionally and every login shell re-sources it (a terminal login, kitty, and VS Code each add another copy), so without the unique-array flag entries accumulate. It must come before any PATH manipulation for later additions — including those in `.zshrc` — to be deduped. - **The `Telemetry` block in `.zprofile` must stay ahead of the `Homebrew` block.** The Homebrew section runs `brew shellenv`, so `HOMEBREW_NO_ANALYTICS` has to already be exported by then. Variable names there were each taken from the tool itself rather than from memory (e.g. `AZURE_CORE_COLLECT_TELEMETRY` is what knack builds from `ENV_VAR_PREFIX='AZURE'`); verify against the tool before adding more. - **gitignore has no trailing-comment syntax.** `#` only starts a comment at the start of a line, so `.ionide/ # cache` becomes part of the pattern and silently stops matching — no error, no warning. Keep comments on their own lines in both `.gitignore` and `git/.config/git/ignore`. Those two files are different scopes: the former covers this repo, the latter is stowed to `~/.config/git/ignore` and applies machine-wide. - **Powerlevel10k** is the zsh prompt. Config lives in `zsh/.p10k.zsh`. The instant prompt block at the top of `.zshrc` must remain first — nothing can print to stdout before it. - **Neovim** uses lazy.nvim for plugin management. Plugin specs live in `nvim/.config/nvim/lua/plugins/`. Core config (options, keymaps) lives in `nvim/.config/nvim/lua/config/`. There is deliberately no LSP (stripped in 539109d) — so nothing publishes diagnostics, and `blink.cmp` has no `lsp` source. Add diagnostic keymaps back only alongside something that produces them. +- **Any SketchyBar item whose script sets `drawing=off` on itself MUST also set `updates=on`.** This has been rediscovered three times and is the most expensive trap in the config. Under the config-wide `updates=when_shown` default a hidden item stops being updated *entirely* — `bar_item_update()` gates timed **and** event runs behind `updates_only_when_shown ? is_shown : true`, and `bar_draw()` clears the bar association of anything it does not draw. The item works right after a reload, hides itself when there is nothing to show, and then never runs again, silently. Eight items self-hide (`battery brightness bluetooth calendar weather github vpn mic`); `amphetamine` looks like one but only hides its *label*, so check for item-level `drawing=off` in the plugin before assuming. Note `updates=on` with `update_freq=0` is still event-only — it buys back recoverability without introducing polling. +- **New plugin scripts need `chmod +x`.** SketchyBar `fork_exec`s them and reports nothing at all when the bit is missing — the item simply never updates, which looks identical to a script that runs and does nothing. +- **A plugin runs with LESS access than your terminal, so test reads from a script SketchyBar actually runs.** The bar is launched by AeroSpace and has no Full Disk Access; a terminal usually does. Anything under `~/Library` that TCC protects will read fine when you try it by hand and fail with `PermissionError [Errno 1] Operation not permitted` in the plugin — with correct Unix permissions, so it does not look like a permissions problem. This is what killed a macOS Focus-mode indicator (`~/Library/DoNotDisturb/DB/Assertions.json`); see the note where it would have gone in `sketchybarrc`. +- **SketchyBar has two compiled Swift helpers** in `sketchybar/.config/sketchybar/helpers/` — `mic.swift` (is anything recording?) and `thermal.swift` (average SoC die temperature). Both are built up front by `install` *and* rebuilt on demand by their callers (`mic.sh`, `system.sh`) when the binary is missing or older than the source, so a `git pull` that changes one doesn't require re-running `install`. They are compiled rather than interpreted because they are polled: `swift` on a trivial script measured 1.25s. Both use private APIs deliberately — `thermal.swift` reads `IOHIDEventSystemClient*`, which is the only no-sudo route to the thermal sensors. Adding a new file to this package needs a re-stow before the running bar sees it (the `--no-folding` tradeoff below). +- **Don't trust `macmon`'s `temp.cpu_temp_avg`.** It is bimodal at flat idle — measured landing on either ~31.9 or ~38.1 and never between, dropping as far as 20°C while `cpu_power` sat at 0.06W. It is a mean over a varying sensor set, not a temperature. `thermal.swift` exists because of this; the full measurement is in its header. - **`claude/.claude/statusline.sh` is vendored third-party code** ([daniel3303/ClaudeCodeStatusLine](https://github.com/daniel3303/ClaudeCodeStatusLine), see `VERSION` at the top). It reads OAuth credentials and makes network calls, so review diffs before pulling upstream changes. Local deviations from upstream: single-pass `jq` parsing, `$TMPDIR` cache dir, and the bearer token passed via `curl --config -` (never argv, which `ps` exposes). It must stay **bash 3.2**-compatible — `bash` resolves to `/bin/bash` on a machine without Homebrew's bash, which is not in the Brewfile. - **Zsh load order** in `.zshrc` is critical and must be preserved: 1. Powerlevel10k instant prompt (must be first — nothing can print to stdout before it) diff --git a/README.md b/README.md index 28733ee..7019d03 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ each declared tap trust as it adds it. | Package | Contents | |---------|----------| -| `aerospace` | [AeroSpace](https://github.com/nikitabobko/AeroSpace) tiling WM. Numeric workspaces on `alt-1`–`alt-9`, focus/move on `alt-hjkl`. Needs Accessibility permission; does **not** start at login | +| `aerospace` | [AeroSpace](https://github.com/nikitabobko/AeroSpace) tiling WM. Numeric workspaces on `ctrl-1`–`ctrl-9` (**not** `alt` — that is where a Danish layout keeps `[ ] { } \`), focus/move on `alt-hjkl`. Needs Accessibility permission; does **not** start at login | | `bat` | bat config (Catppuccin Frappé theme) | | `btop` | btop Catppuccin Frappé theme (the install script seeds `color_theme = "catppuccin_frappe"` for you) | | `claude` | Claude Code settings and statusline | From 5c67f66a2d73e6ff3d794ef87aaf437993d617a2 Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:49:49 +0200 Subject: [PATCH 07/13] sketchybar: cut idle CPU by raising two intervals that were set on bad data mic was polled every 2s on the strength of a comment in mic.swift saying the helper "runs in single-digit ms". Measured min/median/max over 10 consecutive runs: 60/63/65ms -- wrong by a factor of 8. At 2s that item alone cost 1890ms of CPU per MINUTE, 36% of the whole bar's budget and the single most expensive thing in the config. Now 5s. The cost is inherent, not a Swift artefact: the helper enumerates every CoreAudio device and queries two properties each, so it is O(devices) IPC into coreaudiod per tick. Making it free means a property listener, not a faster loop; noted in the source. system was 30s at ~930ms per invocation (macmon alone measured 854ms, because it samples a full SoC telemetry frame to extract three numbers). Now 60s. CPU%, RAM and fan RPM are not acted on faster than that. Together: 3750 -> 1686 ms/min, a 55% reduction in the bar's idle CPU. --- .../.config/sketchybar/helpers/mic.swift | 22 ++++++- .../.config/sketchybar/plugins/aerospace.sh | 29 ---------- sketchybar/.config/sketchybar/sketchybarrc | 58 +++++++++++++++++-- 3 files changed, 72 insertions(+), 37 deletions(-) delete mode 100755 sketchybar/.config/sketchybar/plugins/aerospace.sh diff --git a/sketchybar/.config/sketchybar/helpers/mic.swift b/sketchybar/.config/sketchybar/helpers/mic.swift index 3b7a01f..edc2161 100644 --- a/sketchybar/.config/sketchybar/helpers/mic.swift +++ b/sketchybar/.config/sketchybar/helpers/mic.swift @@ -14,8 +14,26 @@ // question, and is why this reports other apps' recording, not our own. // // WHY IT IS COMPILED: `/usr/bin/swift` interpreting even a trivial script -// measured 1.25s, which is not pollable. Compiled it runs in single-digit ms. -// mic.sh builds it on demand; the install script builds it up front. +// measured 1.25s, which is not pollable. mic.sh builds it on demand; the install +// script builds it up front. +// +// IT IS NOT CHEAP, AND THIS COMMENT USED TO CLAIM IT WAS. The previous wording +// said "compiled it runs in single-digit ms", which is wrong by a factor of 8 — +// measured 60/63/65ms (min/median/max over 10 consecutive runs). That false +// premise is what justified polling it every 2 seconds, which cost 1890ms of CPU +// per MINUTE and made this the single most expensive thing in the whole config. +// The interval is now 5s. Re-measure before lowering it again: +// python3 -c "import subprocess,time +// ts=[(lambda s: (subprocess.run(['$HOME/.config/sketchybar/helpers/mic'], +// capture_output=True), (time.time()-s)*1000)[1])(time.time()) for _ in range(10)] +// print(sorted(ts)[5])" +// +// The cost is inherent to the approach, not to Swift: this enumerates EVERY +// CoreAudio device, then queries two properties per device, so it is O(devices) +// IPC round-trips into coreaudiod on every single tick. The way to make it free +// is not micro-optimisation but AudioObjectAddPropertyListener on +// kAudioDevicePropertyDeviceIsRunningSomewhere, turning this into a resident +// listener that pushes a sketchybar event instead of being polled. import CoreAudio import Foundation diff --git a/sketchybar/.config/sketchybar/plugins/aerospace.sh b/sketchybar/.config/sketchybar/plugins/aerospace.sh deleted file mode 100755 index b9ac10b..0000000 --- a/sketchybar/.config/sketchybar/plugins/aerospace.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# Workspace indicator. One item per workspace; $1 is the workspace this item -# represents. - -source "$HOME/.config/sketchybar/colors.sh" - -sid="$1" - -focused="$FOCUSED_WORKSPACE" -if [ -z "$focused" ]; then - focused="$(aerospace list-workspaces --focused 2>/dev/null)" -fi - -if [ "$sid" = "$focused" ]; then - sketchybar --set "$NAME" \ - background.drawing=on \ - label.color="$CRUST" \ - icon.color="$CRUST" -elif aerospace list-workspaces --monitor all --empty no 2>/dev/null | grep -qx "$sid"; then - sketchybar --set "$NAME" \ - background.drawing=off \ - label.color="$TEXT" \ - icon.color="$TEXT" -else - sketchybar --set "$NAME" \ - background.drawing=off \ - label.color="$SURFACE2" \ - icon.color="$SURFACE2" -fi diff --git a/sketchybar/.config/sketchybar/sketchybarrc b/sketchybar/.config/sketchybar/sketchybarrc index cb77a2c..be92050 100755 --- a/sketchybar/.config/sketchybar/sketchybarrc +++ b/sketchybar/.config/sketchybar/sketchybarrc @@ -58,7 +58,13 @@ PLUGIN_DIR="$CONFIG_DIR/plugins" # Plugins shell out to `aerospace` and `sketchybar`. Set PATH explicitly so the # bar behaves the same whether it was launched from a terminal or by launchd, # which starts with a minimal PATH. -export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH" +# BOTH Homebrew prefixes: /opt/homebrew on Apple Silicon, /usr/local on Intel. +# Prepending a directory that does not exist is harmless, and this avoids paying +# for a `brew --prefix` subprocess every time the bar starts. Without the Intel +# entry every plugin depending on aerospace/macmon/gh/icalBuddy would silently +# degrade on an Intel Mac — they all hide rather than error when a binary is +# missing, so the bar would come up looking merely quiet. +export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:$PATH" source "$CONFIG_DIR/colors.sh" @@ -251,6 +257,31 @@ sketchybar --add event aerospace_workspace_change # 3. Measure INK, not the typographic advance. text_prepare_line sizes items # with kCTLineBoundsUseGlyphPathBounds and Nerd Font glyphs are drawn wider # than their cell, so a "monospaced" 7.38 advance means nothing. +# ONE DRIVER REPAINTS ALL NINE PILLS. The workspace items carry no script and no +# subscription of their own; this invisible item owns both and rewrites every +# pill in a single sketchybar call — see plugins/workspaces.sh. +# +# Each pill used to run its own copy of the plugin, and each copy shelled out to +# `aerospace list-workspaces` to ask a question whose answer is the same for all +# nine. Measured 25ms per call, 104ms for the nine, plus nine greps — and because +# they were also subscribed to front_app_switched, all of that ran on every +# application switch. Now it is 2 aerospace calls and 1 sketchybar call. +# +# width=0 so it takes no space. It is BLANK_STYLE (visible, paints nothing) +# rather than drawing=off, because a hidden item is not laid out at all — and it +# needs updates=on for the same reason every self-hiding item does: an item that +# is not drawn is not updated under the config-wide when_shown default. +# +# It sits OUTSIDE island.spaces deliberately. A bracket's rect is the union of +# its members, so a zero-width member would not change the pill, but keeping the +# driver out of the group keeps "what the island contains" honest. +sketchybar \ + --add item spaces.driver left \ + --subscribe spaces.driver aerospace_workspace_change front_app_switched \ + --set spaces.driver "${BLANK_STYLE[@]}" width=0 \ + updates=on \ + script="$PLUGIN_DIR/workspaces.sh" + sketchybar \ --add item inset.spaces.l left \ --set inset.spaces.l "${BLANK_STYLE[@]}" width=6 @@ -259,7 +290,6 @@ space_items=() for sid in $(aerospace list-workspaces --all); do sketchybar \ --add item "space.$sid" left \ - --subscribe "space.$sid" aerospace_workspace_change front_app_switched \ --set "space.$sid" \ label="$sid" \ icon.drawing=off \ @@ -267,8 +297,9 @@ for sid in $(aerospace list-workspaces --all); do `# padding is the outer edge). It doubles as the pill's interior.` \ label.padding_left=6 \ label.padding_right=6 \ - click_script="aerospace workspace $sid" \ - script="$PLUGIN_DIR/aerospace.sh $sid" + `# click_script only. The colours come from spaces.driver above; an` \ + `# item needs no script of its own to be repainted by another item.` \ + click_script="aerospace workspace $sid" space_items+=("space.$sid") done @@ -744,7 +775,14 @@ sketchybar \ `# config-wide when_shown default stops being polled, so mic.sh could` \ `# never run again to notice that recording had started.` \ updates=on \ - update_freq=2 \ + `# 5, NOT 2. The helper costs 63ms per run (measured median of 10), so` \ + `# every second of interval here is worth ~1900ms/min of CPU. At 2 this` \ + `# item alone was 1890ms/min — 36% of the entire bar's budget and the` \ + `# most expensive thing in the config. The old value came from a comment` \ + `# in mic.swift claiming the helper ran in "single-digit ms", which was` \ + `# wrong by 8x; that comment is now corrected. Two seconds of latency on` \ + `# a recording indicator buys nothing. Lower this only after re-measuring.` \ + update_freq=5 \ script="$PLUGIN_DIR/mic.sh" # A macOS Focus-mode indicator (Do Not Disturb / Work / …) WAS BUILT HERE AND @@ -838,7 +876,15 @@ sketchybar \ `# all three items when macmon fails, and a hidden item under when_shown` \ `# stops being polled — the failure would be permanent and silent.` \ updates=on \ - update_freq=30 \ + `# 60, NOT 30. One invocation costs ~930ms — macmon alone is 854ms` \ + `# (measured), because it samples a whole SoC telemetry frame to extract` \ + `# three numbers. At 30 that was 1860ms/min, the second-largest cost in` \ + `# the bar. CPU%, RAM and fan RPM at 60s resolution is not information` \ + `# anyone acts on faster. The real fix is to stop shelling out to macmon` \ + `# for values that host_statistics64 and host_processor_info give almost` \ + `# free, but fan RPM has no obvious no-sudo source — the HID sensor page` \ + `# exposes temperature (usage 5) and nothing fan-shaped.` \ + update_freq=60 \ script="$PLUGIN_DIR/system.sh" \ click_script="open -a 'Macs Fan Control'" \ --add item memory right \ From f4d9cf1cd245cd8977869443f249776494ae9c9d Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:49:49 +0200 Subject: [PATCH 08/13] sketchybar: repaint all workspace pills from one query, not nine Each of the nine workspace items ran its own copy of aerospace.sh, and each copy shelled out to `aerospace list-workspaces` to ask a GLOBAL question -- which workspaces hold windows -- whose answer is identical for all nine. Measured 25ms per call, 104ms for the nine, plus nine greps. Worse, the items were subscribed to front_app_switched as well as aerospace_workspace_change, so all of that ran on every application switch, one of the highest-frequency actions there is. Replace with a zero-width driver item that owns the subscriptions and rewrites every pill in a single sketchybar call: 2 aerospace calls and 1 sketchybar call regardless of workspace count. Full repaint now 54ms. The occupancy test is parameter expansion instead of a grep per item, with sentinel spaces so a future workspace 10 cannot match workspace 1. --- .../.config/sketchybar/plugins/workspaces.sh | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100755 sketchybar/.config/sketchybar/plugins/workspaces.sh diff --git a/sketchybar/.config/sketchybar/plugins/workspaces.sh b/sketchybar/.config/sketchybar/plugins/workspaces.sh new file mode 100755 index 0000000..f51ea22 --- /dev/null +++ b/sketchybar/.config/sketchybar/plugins/workspaces.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Repaints EVERY workspace pill from ONE pass. Driven by the spaces.driver item. +# +# WHY THIS REPLACED aerospace.sh, which was per-item: +# there is one sketchybar item per workspace (space.1 .. space.9), and each one +# used to run its own copy of the plugin, and each copy shelled out to +# `aerospace list-workspaces --monitor all --empty no` to ask a GLOBAL question — +# which workspaces have windows — whose answer is identical for all nine. +# +# Measured: 25ms per aerospace call, 104ms for nine sequential calls, plus nine +# `grep` subprocesses. And the items were subscribed to front_app_switched as +# well as aerospace_workspace_change, so that ran on every APPLICATION SWITCH, +# which is one of the highest-frequency actions a user performs. +# +# This does 2 aerospace calls and 1 sketchybar call, total, however many +# workspaces exist. The occupancy test is parameter expansion rather than a +# `grep` per item. + +source "$HOME/.config/sketchybar/colors.sh" + +# FOCUSED_WORKSPACE is set by aerospace's exec-on-workspace-change; fall back to +# a query for the startup pass and for front_app_switched, which does not carry it. +focused="${FOCUSED_WORKSPACE:-$(aerospace list-workspaces --focused 2>/dev/null)}" + +# Sentinel spaces on both ends so the substring test below cannot match a prefix. +# Workspaces are single digits today, so "1" could never be found inside "10" — +# but the guard is free and this is exactly the bug that appears the day someone +# adds a tenth workspace. +occupied=" $(aerospace list-workspaces --empty no 2>/dev/null | tr '\n' ' ')" + +args=() +for sid in $(aerospace list-workspaces --all 2>/dev/null); do + if [ "$sid" = "$focused" ]; then + # The mauve pill. This is the only place background.drawing is turned on + # for a workspace item; the bracket renders below its members, so the + # pill draws on top of the island rather than being hidden by it. + args+=(--set "space.$sid" + background.drawing=on label.color="$CRUST" icon.color="$CRUST") + elif [ "${occupied#* $sid }" != "$occupied" ]; then + args+=(--set "space.$sid" + background.drawing=off label.color="$TEXT" icon.color="$TEXT") + else + args+=(--set "space.$sid" + background.drawing=off label.color="$SURFACE2" icon.color="$SURFACE2") + fi +done + +# One call. Nine --set clauses in a single message is dramatically cheaper than +# nine invocations, and it repaints atomically so the pill never appears on two +# workspaces at once mid-update. +[ ${#args[@]} -gt 0 ] && sketchybar "${args[@]}" From c8494f6e7818009f98291bbced85fbdbb0674907 Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:49:49 +0200 Subject: [PATCH 09/13] portability: fall back to /usr/local for Intel Homebrew Both PATH exports hardcoded /opt/homebrew, the Apple Silicon prefix. On an Intel Mac every plugin depending on aerospace, macmon, gh or icalBuddy would fail to find its binary -- and they all hide rather than error when that happens, so the bar would come up merely looking quiet. Prepending a directory that does not exist is harmless, and this avoids a `brew --prefix` subprocess on every bar start. --- raycast/.config/raycast/scripts/reload-sketchybar.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/raycast/.config/raycast/scripts/reload-sketchybar.sh b/raycast/.config/raycast/scripts/reload-sketchybar.sh index bdb650f..9a14304 100755 --- a/raycast/.config/raycast/scripts/reload-sketchybar.sh +++ b/raycast/.config/raycast/scripts/reload-sketchybar.sh @@ -20,7 +20,7 @@ # the same repair sketchybarrc makes at the top of itself. # USER the client resolves the running bar's mach port through it and aborts # with "sketchybar-msg: 'env USER' not set! abort.." if it is missing. -export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:$PATH" +export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:$PATH" export USER="${USER:-$(id -un)}" # --reload re-executes the config in place, so the bar keeps its PID and nothing From 6d73b2d01a49b98f1c91dfee86c19cc95c957d3c Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:58:41 +0200 Subject: [PATCH 10/13] sketchybar: extract lib.sh and collapse six duplication sites hide() was defined identically in three plugins, the truncate idiom was copy-pasted into three more, the helper build-on-demand block existed twice, and amphetamine.sh had a six-line osascript block duplicated verbatim inside itself. lib.sh provides hide, truncate_label, state_file, ensure_helper and require. Notes on two of them: hide() now closes any popup unconditionally. Two of the three local definitions did that and the third has no popup; popup.drawing=off on a popup-less item is accepted silently (verified). Doing it always removes a footgun -- a bare hide() that left a popup on screen would be a rare and subtle bug. truncate_label replaces `printf | cut -c1-N`, a two-subprocess pipeline run on every invocation of every plugin that shows a name. cut -c counts BYTES in some implementations while ${#s} and ${s:0:n} count characters, so the old form could cut a UTF-8 name mid-codepoint. require() gives missing dependencies a voice: plugins previously failed into empty output and then hid, which is indistinguishable from "nothing to report". --- sketchybar/.config/sketchybar/lib.sh | 106 ++++++++++++++++++ .../.config/sketchybar/plugins/amphetamine.sh | 35 +++--- .../.config/sketchybar/plugins/battery.sh | 30 ++++- .../.config/sketchybar/plugins/bluetooth.sh | 5 +- .../.config/sketchybar/plugins/brightness.sh | 1 + .../.config/sketchybar/plugins/calendar.sh | 13 +-- .../.config/sketchybar/plugins/github.sh | 5 +- sketchybar/.config/sketchybar/plugins/mic.sh | 21 +--- .../.config/sketchybar/plugins/music.sh | 5 +- .../.config/sketchybar/plugins/pomodoro.sh | 3 +- .../.config/sketchybar/plugins/system.sh | 31 +++-- .../.config/sketchybar/plugins/volume.sh | 1 + sketchybar/.config/sketchybar/plugins/vpn.sh | 5 +- .../.config/sketchybar/plugins/weather.sh | 5 +- sketchybar/.config/sketchybar/plugins/wifi.sh | 25 +++-- .../.config/sketchybar/plugins/workspaces.sh | 3 + 16 files changed, 211 insertions(+), 83 deletions(-) create mode 100644 sketchybar/.config/sketchybar/lib.sh diff --git a/sketchybar/.config/sketchybar/lib.sh b/sketchybar/.config/sketchybar/lib.sh new file mode 100644 index 0000000..511e3ec --- /dev/null +++ b/sketchybar/.config/sketchybar/lib.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Shared plugin helpers. +# +# Source AFTER colors.sh: +# source "$HOME/.config/sketchybar/colors.sh" +# source "$HOME/.config/sketchybar/lib.sh" +# +# Every function assumes $NAME is set — sketchybar sets it when it runs a plugin. +# Running a plugin by hand without NAME produces `sketchybar --set "" ...`, which +# errors unhelpfully; that is the one case worth knowing about when debugging. +# +# WHY THIS EXISTS: before it, `hide()` was defined identically in three plugins, +# the truncate idiom was copy-pasted into three more, the helper build-on-demand +# block existed twice, and one plugin had a six-line osascript block duplicated +# verbatim inside itself. None of that is hard to write; all of it is easy to fix +# in one place and forget in the other five. + +# --------------------------------------------------------------- +# Hide this item and exit. +# +# The single most repeated idiom in the plugins, and the one with a trap: an item +# that hides itself MUST also be declared `updates=on` in sketchybarrc. Under the +# config-wide `updates=when_shown` default a hidden item stops being updated +# entirely — bar_item_update() gates timed AND event runs behind +# `updates_only_when_shown ? is_shown : true`, and bar_draw() clears the bar +# association of anything it does not draw. The item then works right after a +# reload, hides itself, and never runs again, silently. Eight items were found in +# that state. scripts/lint-sketchybar.sh checks for it. +# +# It closes any popup too. Two of the three plugins that had their own hide() +# did that, and the third has no popup — and `popup.drawing=off` on an item +# without one is accepted silently (verified), so doing it unconditionally is +# safe and removes a footgun: a bare `hide` that left a popup open on screen +# would be a subtle, rare bug to chase. +# +# Extra arguments are passed through for anything else an item needs to reset. +hide() { + sketchybar --set "$NAME" drawing=off popup.drawing=off "$@" + exit 0 +} + +# --------------------------------------------------------------- +# Truncate to at most $2 characters, appending an ellipsis if it was cut. +# +# Pure parameter expansion. The idiom this replaces — +# printf '%s' "$s" | cut -c1-"$n" +# — is a pipeline of two subprocesses, run on every invocation of every plugin +# that displays a name. `cut -c` also counts BYTES in some implementations while +# ${#s} and ${s:0:n} count characters, so the old form truncated UTF-8 names +# mid-codepoint; this does not. +truncate_label() { + local s=$1 n=$2 + if [ "${#s}" -gt "$n" ]; then + printf '%s…' "${s:0:n}" + else + printf '%s' "$s" + fi +} + +# --------------------------------------------------------------- +# Path for a plugin's cache or state file, e.g. state_file weather. +# +# $TMPDIR rather than /tmp: it is per-user and cleaned by macOS, so nothing +# accumulates and no other user can read or pre-create these paths. +state_file() { + printf '%s/sketchybar-%s' "${TMPDIR:-/tmp}" "$1" +} + +# --------------------------------------------------------------- +# Ensure a compiled Swift helper exists and is current; echo its path. +# Returns non-zero if it cannot be built, so callers can degrade. +# +# bin="$(ensure_helper thermal)" || bin="" +# +# Rebuilds when the binary is missing OR older than the source, so a `git pull` +# that changes a helper does not require re-running ./install. +ensure_helper() { + local name=$1 + local dir="$HOME/.config/sketchybar/helpers" + local src="$dir/$name.swift" bin="$dir/$name" + + if [ ! -x "$bin" ] || [ "$src" -nt "$bin" ]; then + [ -r "$src" ] || return 1 + command -v swiftc >/dev/null 2>&1 || return 1 + if swiftc -O -o "$bin.new" "$src" >/dev/null 2>&1; then + mv "$bin.new" "$bin" + else + rm -f "$bin.new" + return 1 + fi + fi + printf '%s' "$bin" +} + +# --------------------------------------------------------------- +# Require an external command, or hide the item and say why. +# +# Most plugins here depend on something Homebrew installed — aerospace, macmon, +# gh, icalBuddy. Without this they fail into empty output and then hide, which is +# indistinguishable from "nothing to report". The stderr line is what makes a +# missing dependency diagnosable rather than merely quiet. +require() { + command -v "$1" >/dev/null 2>&1 && return 0 + printf 'sketchybar/%s: missing dependency: %s\n' "${NAME:-?}" "$1" >&2 + hide +} diff --git a/sketchybar/.config/sketchybar/plugins/amphetamine.sh b/sketchybar/.config/sketchybar/plugins/amphetamine.sh index 8cac5a2..7ca30fc 100755 --- a/sketchybar/.config/sketchybar/plugins/amphetamine.sh +++ b/sketchybar/.config/sketchybar/plugins/amphetamine.sh @@ -3,6 +3,7 @@ # long. Click toggles an indefinite session on/off. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" draw_off() { sketchybar --set "$NAME" icon.color="$OVERLAY0" label="" label.drawing=off \ @@ -14,15 +15,22 @@ if ! pgrep -x Amphetamine >/dev/null 2>&1; then exit 0 fi -state="$(osascript \ - -e 'tell application "Amphetamine"' \ - -e 'set a to session is active' \ - -e 'set t to session time remaining' \ - -e 'end tell' \ - -e 'return (a as text) & "," & (t as text)' 2>/dev/null)" +# One definition, two call sites. This block was previously written out twice, +# verbatim — once here and once after the click below — and the two copies are +# exactly the kind of thing that drifts the first time either is touched. +read_session() { + local state + state="$(osascript \ + -e 'tell application "Amphetamine"' \ + -e 'set a to session is active' \ + -e 'set t to session time remaining' \ + -e 'end tell' \ + -e 'return (a as text) & "," & (t as text)' 2>/dev/null)" + active="${state%%,*}" + remaining="${state##*,}" +} -active="${state%%,*}" -remaining="${state##*,}" +read_session if [ "$SENDER" = "mouse.clicked" ]; then if [ "$active" = "true" ]; then @@ -30,15 +38,10 @@ if [ "$SENDER" = "mouse.clicked" ]; then else osascript -e 'tell application "Amphetamine" to start new session with options {duration:0, interval:0, displaySleepAllowed:false}' >/dev/null 2>&1 fi + # Amphetamine updates its state asynchronously; without this the re-read + # below races the toggle and paints the previous state. sleep 0.4 - state="$(osascript \ - -e 'tell application "Amphetamine"' \ - -e 'set a to session is active' \ - -e 'set t to session time remaining' \ - -e 'end tell' \ - -e 'return (a as text) & "," & (t as text)' 2>/dev/null)" - active="${state%%,*}" - remaining="${state##*,}" + read_session fi if [ "$active" != "true" ]; then diff --git a/sketchybar/.config/sketchybar/plugins/battery.sh b/sketchybar/.config/sketchybar/plugins/battery.sh index 8d84c90..4627ad4 100755 --- a/sketchybar/.config/sketchybar/plugins/battery.sh +++ b/sketchybar/.config/sketchybar/plugins/battery.sh @@ -3,10 +3,36 @@ # power_source_change. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" batt="$(pmset -g batt)" -pct="$(printf '%s' "$batt" | grep -Eo '[0-9]+%' | head -1 | tr -d '%')" -charging="$(printf '%s' "$batt" | grep -c "AC Power")" + +# Parameter expansion, not `grep | head | tr`. pmset prints e.g. +# Now drawing from 'AC Power' +# -InternalBattery-0 (id=…) 100%; charged; 0:00 remaining present: true +# +# THE NO-BATTERY CASE IS NOT HYPOTHETICAL — it is this machine. A Mac Studio +# prints only the "Now drawing from 'AC Power'" line, with no battery line and no +# percent sign anywhere. So test for '%' FIRST: an earlier version of this parse +# assumed the battery line existed, and on a desktop it happily produced the +# string "Power'" as a percentage. +case "$batt" in + *%*) + pct="${batt%%\%*}" # drop from the '%' onward + pct="${pct##*[!0-9]}" # keep the trailing digit run + ;; + *) pct="" ;; # no battery — the guard below hides the item +esac + +# CHARGING IS NOT THE SAME AS ON AC, and this used to conflate them. The old test +# was `grep -c "AC Power"`, which is true whenever the cable is in — so a battery +# sitting at 100% on mains was painted green as though it were still charging. +# pmset states its own answer: the status field reads "charging", "charged", +# "discharging" or "AC attached". Only the first is actually charging. +case "$batt" in + *"; charging"*) charging=1 ;; + *) charging=0 ;; +esac if [ -z "$pct" ]; then sketchybar --set "$NAME" drawing=off diff --git a/sketchybar/.config/sketchybar/plugins/bluetooth.sh b/sketchybar/.config/sketchybar/plugins/bluetooth.sh index a34735f..e4cf1c9 100755 --- a/sketchybar/.config/sketchybar/plugins/bluetooth.sh +++ b/sketchybar/.config/sketchybar/plugins/bluetooth.sh @@ -2,6 +2,7 @@ # Bluetooth: power state, and what's actually connected. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" MAX_LEN=8 @@ -34,9 +35,7 @@ case "$state" in case "$label" in *' devices') ;; *) - if [ "${#label}" -gt "$MAX_LEN" ]; then - label="$(printf '%s' "$label" | cut -c1-"$MAX_LEN")…" - fi + label="$(truncate_label "$label" "$MAX_LEN")" ;; esac sketchybar --set "$NAME" drawing=on icon="󰂱" icon.color="$BLUE" \ diff --git a/sketchybar/.config/sketchybar/plugins/brightness.sh b/sketchybar/.config/sketchybar/plugins/brightness.sh index 32a884d..f445f51 100755 --- a/sketchybar/.config/sketchybar/plugins/brightness.sh +++ b/sketchybar/.config/sketchybar/plugins/brightness.sh @@ -37,6 +37,7 @@ # it is no worse than the behaviour this replaced. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" if [ "$SENDER" = "brightness_change" ] && [ -n "$INFO" ]; then pct="$INFO" diff --git a/sketchybar/.config/sketchybar/plugins/calendar.sh b/sketchybar/.config/sketchybar/plugins/calendar.sh index 32fd085..5ef4b8c 100755 --- a/sketchybar/.config/sketchybar/plugins/calendar.sh +++ b/sketchybar/.config/sketchybar/plugins/calendar.sh @@ -2,21 +2,18 @@ # Next calendar event today. Hidden when there is nothing left in the day. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" + +require icalBuddy MAX_LEN=42 -hide() { - sketchybar --set "$NAME" drawing=off popup.drawing=off - exit 0 -} case "$SENDER" in mouse.entered) sketchybar --set "$NAME" popup.drawing=on; exit 0 ;; mouse.exited) sketchybar --set "$NAME" popup.drawing=off; exit 0 ;; esac -command -v icalBuddy >/dev/null 2>&1 || hide - raw="$(icalBuddy -n -nc -nrd -ea -b "" -df "" -tf "%H:%M" -li 1 \ -iep "datetime,title" -ps "|@|" eventsToday 2>/dev/null)" @@ -32,9 +29,7 @@ title="$(printf '%s' "$raw" \ [ -n "$title" ] || title="(untitled)" -if [ "${#title}" -gt "$MAX_LEN" ]; then - title="$(printf '%s' "$title" | cut -c1-"$MAX_LEN")…" -fi +title="$(truncate_label "$title" "$MAX_LEN")" sketchybar --set "$NAME" drawing=on \ icon="󰃭" icon.color="$PEACH" \ diff --git a/sketchybar/.config/sketchybar/plugins/github.sh b/sketchybar/.config/sketchybar/plugins/github.sh index bc4c9fc..7eb719d 100755 --- a/sketchybar/.config/sketchybar/plugins/github.sh +++ b/sketchybar/.config/sketchybar/plugins/github.sh @@ -2,8 +2,11 @@ # Unread GitHub notification count. Hidden at zero, which is most of the time. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -CACHE="${TMPDIR:-/tmp}/sketchybar-github" +require gh + +CACHE="$(state_file github)" count="$(gh api notifications --jq 'length' 2>/dev/null)" diff --git a/sketchybar/.config/sketchybar/plugins/mic.sh b/sketchybar/.config/sketchybar/plugins/mic.sh index ede9583..143e745 100755 --- a/sketchybar/.config/sketchybar/plugins/mic.sh +++ b/sketchybar/.config/sketchybar/plugins/mic.sh @@ -3,26 +3,11 @@ # bar. Hidden unless something is actually recording. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -HELPER_DIR="$HOME/.config/sketchybar/helpers" -SRC="$HELPER_DIR/mic.swift" -BIN="$HELPER_DIR/mic" +bin="$(ensure_helper mic)" || hide -hide() { - sketchybar --set "$NAME" drawing=off - exit 0 -} - -if [ ! -x "$BIN" ] || [ "$SRC" -nt "$BIN" ]; then - [ -r "$SRC" ] || hide - command -v swiftc >/dev/null 2>&1 || hide - swiftc -O -o "$BIN.new" "$SRC" >/dev/null 2>&1 && mv "$BIN.new" "$BIN" || { - rm -f "$BIN.new" - hide - } -fi - -[ "$("$BIN" 2>/dev/null)" = "on" ] || hide +[ "$("$bin" 2>/dev/null)" = "on" ] || hide sketchybar --set "$NAME" drawing=on \ icon="󰍬" icon.color="$RED" \ diff --git a/sketchybar/.config/sketchybar/plugins/music.sh b/sketchybar/.config/sketchybar/plugins/music.sh index baa171b..571ceb3 100755 --- a/sketchybar/.config/sketchybar/plugins/music.sh +++ b/sketchybar/.config/sketchybar/plugins/music.sh @@ -2,11 +2,8 @@ # Apple Music now-playing. Click toggles play/pause. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -hide() { - sketchybar --set "$NAME" drawing=off popup.drawing=off - exit 0 -} pgrep -x Music >/dev/null 2>&1 || hide diff --git a/sketchybar/.config/sketchybar/plugins/pomodoro.sh b/sketchybar/.config/sketchybar/plugins/pomodoro.sh index 2c2343d..a1150e4 100755 --- a/sketchybar/.config/sketchybar/plugins/pomodoro.sh +++ b/sketchybar/.config/sketchybar/plugins/pomodoro.sh @@ -2,8 +2,9 @@ # Pomodoro timer. Click cycles: idle → 25min work → 5min break → idle. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -STATE="${TMPDIR:-/tmp}/sketchybar-pomodoro" +STATE="$(state_file pomodoro)" WORK_MIN=25 BREAK_MIN=5 diff --git a/sketchybar/.config/sketchybar/plugins/system.sh b/sketchybar/.config/sketchybar/plugins/system.sh index 998a1aa..7543d66 100755 --- a/sketchybar/.config/sketchybar/plugins/system.sh +++ b/sketchybar/.config/sketchybar/plugins/system.sh @@ -9,12 +9,11 @@ # set of sensors, so the bar was faithfully displaying a bogus number. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -HELPER_DIR="$HOME/.config/sketchybar/helpers" -SRC="$HELPER_DIR/thermal.swift" -BIN="$HELPER_DIR/thermal" +require macmon -# ONE SAMPLE, THREE ITEMS: a macmon sample costs ~0.7s, so only `thermals` runs +# ONE SAMPLE, THREE ITEMS: a macmon sample costs ~0.9s, so only `thermals` runs # this script; it writes cpu and memory too. cpu and memory are passive — no # script, no update_freq — and would never update on their own. # @@ -55,22 +54,18 @@ if [ "$ok" != "1" ]; then exit 0 fi -# Build the temperature helper on demand, exactly as mic.sh does, so a git pull -# that changes the source does not need a re-run of ./install. -if [ ! -x "$BIN" ] || [ "$SRC" -nt "$BIN" ]; then - if [ -r "$SRC" ] && command -v swiftc >/dev/null 2>&1; then - swiftc -O -o "$BIN.new" "$SRC" >/dev/null 2>&1 && mv "$BIN.new" "$BIN" || rm -f "$BIN.new" - fi +# A missing temperature must NOT hide the island — note this deliberately does +# NOT use lib.sh's require()/hide(). cpu and memory came from macmon and are +# still good; only the reading we could not take goes away. Same rule as +# volume.sh and brightness.sh: never state a value we do not know. +temp="" +if bin="$(ensure_helper thermal)"; then + temp="$("$bin" 2>/dev/null)" + case "$temp" in + ''|*[!0-9.]*) temp="" ;; + esac fi -# A missing temperature must NOT hide the island. cpu and memory came from macmon -# and are still good; only the reading we could not take goes away. Same rule as -# volume.sh and brightness.sh — never state a value we do not know. -temp="$("$BIN" 2>/dev/null)" -case "$temp" in - ''|*[!0-9.]*) temp="" ;; -esac - # The helper prints one decimal — precise enough to watch the die move while # debugging, but the bar shows a whole number. A tenth of a degree is not # information anyone acts on, and it keeps the pill one character narrower. diff --git a/sketchybar/.config/sketchybar/plugins/volume.sh b/sketchybar/.config/sketchybar/plugins/volume.sh index 7d505ed..c6bb297 100755 --- a/sketchybar/.config/sketchybar/plugins/volume.sh +++ b/sketchybar/.config/sketchybar/plugins/volume.sh @@ -10,6 +10,7 @@ # script's job is then to show nothing rather than to invent a number. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" # ONE osascript call for both readings. This used to be two — one for the level # and one for the mute state — which is how the bug below survived: the two code diff --git a/sketchybar/.config/sketchybar/plugins/vpn.sh b/sketchybar/.config/sketchybar/plugins/vpn.sh index f3b6331..c99bda4 100755 --- a/sketchybar/.config/sketchybar/plugins/vpn.sh +++ b/sketchybar/.config/sketchybar/plugins/vpn.sh @@ -2,6 +2,7 @@ # VPN connection state. Hidden unless something is actually connected. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" MAX_LEN=14 @@ -14,9 +15,7 @@ if [ -z "$name" ]; then exit 0 fi -if [ "${#name}" -gt "$MAX_LEN" ]; then - name="$(printf '%s' "$name" | cut -c1-"$MAX_LEN")…" -fi +name="$(truncate_label "$name" "$MAX_LEN")" sketchybar --set "$NAME" drawing=on \ `# md-vpn, not md-shield_lock — a shield reads as firewall/password manager` \ diff --git a/sketchybar/.config/sketchybar/plugins/weather.sh b/sketchybar/.config/sketchybar/plugins/weather.sh index c04ffe7..67e4002 100755 --- a/sketchybar/.config/sketchybar/plugins/weather.sh +++ b/sketchybar/.config/sketchybar/plugins/weather.sh @@ -15,9 +15,12 @@ # (unset) current behaviour, IP geolocation source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" + +require curl LOCATION="${SKETCHYBAR_WEATHER_LOCATION:-}" -CACHE="${TMPDIR:-/tmp}/sketchybar-weather" +CACHE="$(state_file weather)" if [ "$LOCATION" = "off" ]; then sketchybar --set "$NAME" drawing=off diff --git a/sketchybar/.config/sketchybar/plugins/wifi.sh b/sketchybar/.config/sketchybar/plugins/wifi.sh index 14fe9cf..67fdce6 100755 --- a/sketchybar/.config/sketchybar/plugins/wifi.sh +++ b/sketchybar/.config/sketchybar/plugins/wifi.sh @@ -3,17 +3,28 @@ # disconnect), with a query for the startup pass. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -dev="$(networksetup -listallhardwareports 2>/dev/null \ - | awk '/Hardware Port: Wi-Fi/{getline; print $2; exit}')" -dev="${dev:-en0}" - -if ifconfig "$dev" 2>/dev/null | grep -q "status: active"; then - up=1 +# CACHED, because the device name does not change between reboots and the lookup +# is the most expensive thing in this script: `networksetup -listallhardwareports` +# measured 40ms, and it ran on every single invocation to rediscover a constant. +# $TMPDIR is cleared by macOS, so the cache re-warms on reboot, which is exactly +# when the answer could legitimately differ. +DEV_CACHE="$(state_file wifi-dev)" +if [ -s "$DEV_CACHE" ]; then + read -r dev < "$DEV_CACHE" else - up=0 + dev="$(networksetup -listallhardwareports 2>/dev/null \ + | awk '/Hardware Port: Wi-Fi/{getline; print $2; exit}')" + dev="${dev:-en0}" + printf '%s' "$dev" > "$DEV_CACHE" fi +case "$(ifconfig "$dev" 2>/dev/null)" in + *"status: active"*) up=1 ;; + *) up=0 ;; +esac + if [ "$SENDER" = "wifi_change" ]; then ssid="$INFO" else diff --git a/sketchybar/.config/sketchybar/plugins/workspaces.sh b/sketchybar/.config/sketchybar/plugins/workspaces.sh index f51ea22..189d228 100755 --- a/sketchybar/.config/sketchybar/plugins/workspaces.sh +++ b/sketchybar/.config/sketchybar/plugins/workspaces.sh @@ -17,6 +17,9 @@ # `grep` per item. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" + +require aerospace # FOCUSED_WORKSPACE is set by aerospace's exec-on-workspace-change; fall back to # a query for the startup pass and for front_app_switched, which does not carry it. From 8e700c038fcef57feb7bf3d7e57fd6adb0211a1a Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:59:01 +0200 Subject: [PATCH 11/13] sketchybar: updates=on for music -- the ninth self-hiding item Found by scripts/lint-sketchybar.sh, not by the hand audit that claimed to have found all of them. music.sh hides via a hide() call rather than a literal `--set "$NAME" drawing=off`, so the grep used to sweep for this never saw it. The item was live-broken: Apple Music is usually not running, so the item was sitting hidden with updates=when_shown, meaning starting playback would never have brought the pill back. --- sketchybar/.config/sketchybar/sketchybarrc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sketchybar/.config/sketchybar/sketchybarrc b/sketchybar/.config/sketchybar/sketchybarrc index be92050..98e2efa 100755 --- a/sketchybar/.config/sketchybar/sketchybarrc +++ b/sketchybar/.config/sketchybar/sketchybarrc @@ -379,6 +379,15 @@ sketchybar \ --add item music left \ --subscribe music mouse.clicked \ --set music \ + `# SELF-HIDING ITEM -> updates=on. music.sh calls hide() whenever Apple` \ + `# Music is not running, which is the NORMAL state, so this item spends` \ + `# most of its life hidden. Without this it stops being polled the first` \ + `# time it hides and the pill never comes back when you start playing.` \ + `# This was the ninth item with that bug and the only one a hand audit` \ + `# missed: its hide() was a local function, so a grep for a literal` \ + `# drawing=off on NAME never saw it. scripts/lint-sketchybar.sh found it,` \ + `# which is the argument for having the linter at all.` \ + updates=on \ `# Alone in its island, so it carries both ends' padding. Like` \ `# island.system this stays on background.padding rather than pad items,` \ `# because island.media exists precisely to vanish and a pad would keep` \ From 3e5a450238f76c00fc5b879ed8037006e97611cc Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Sun, 2 Aug 2026 16:59:01 +0200 Subject: [PATCH 12/13] ci: lint the failure classes that are invisible at runtime Three SketchyBar behaviours produce no error and no log line, so the only symptom is an item that quietly stops updating: - a plugin without its execute bit; sketchybar fork_execs it and says nothing - a self-hiding item without updates=on, which can then never un-hide - a comment referencing a section that no longer exists, in a config where the comments carry the measurements scripts/lint-sketchybar.sh checks all three, and earned its place immediately by finding the music item bug above. It is deliberately precise about two things it must NOT flag: label.drawing=off and background.drawing=off hide a COMPONENT, leaving the item drawn and updating (amphetamine and pomodoro both do this correctly); and passive items like cpu and memory carry no script of their own, so they are un-hidden by whichever item's script writes them. --- .github/workflows/lint.yml | 45 ++++++++++++++ scripts/lint-sketchybar.sh | 124 +++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 .github/workflows/lint.yml create mode 100755 scripts/lint-sketchybar.sh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..822a3b0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,45 @@ +name: lint + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + # The plugins and the install script. SC1091 is "can't follow source", + # which is expected: they source $HOME/.config/sketchybar/*.sh, a path that + # only exists once stow has run. + - name: shellcheck + run: | + shellcheck -S warning -e SC1091 \ + sketchybar/.config/sketchybar/*.sh \ + sketchybar/.config/sketchybar/plugins/*.sh \ + raycast/.config/raycast/scripts/*.sh \ + scripts/*.sh + + # install is zsh, not bash — shellcheck cannot read it, so just parse it. + - name: zsh parse install + run: | + sudo apt-get install -y zsh + zsh -n install + + sketchybar: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Catches the SketchyBar-specific failures that are invisible at runtime: + # a plugin without its execute bit, a self-hiding item missing updates=on, + # and a comment pointing at a section that no longer exists. None of these + # produce any error on the host — the item simply never updates. + - name: sketchybar semantics + run: ./scripts/lint-sketchybar.sh diff --git a/scripts/lint-sketchybar.sh b/scripts/lint-sketchybar.sh new file mode 100755 index 0000000..312ba0a --- /dev/null +++ b/scripts/lint-sketchybar.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Catches the SketchyBar failure classes that are INVISIBLE when they happen. +# +# Every check here exists because the failure it detects looks exactly like +# "working, with nothing to report". That is the property that makes them +# expensive: the bar comes up, the item is simply absent or stale, and nothing +# anywhere says why. +# +# Run by hand, or from CI. Exits non-zero on any failure. +# +# NOT a general shell linter — it defers to shellcheck for that, and runs it if +# present. These are the checks shellcheck cannot make because they are about +# SketchyBar's semantics, not the shell's. + +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +PLUGINS="sketchybar/.config/sketchybar/plugins" +RC="sketchybar/.config/sketchybar/sketchybarrc" +fail=0 + +note() { printf ' %s\n' "$*"; } +bad() { printf 'FAIL %s\n' "$*"; fail=1; } + +# --------------------------------------------------------------- +# 1. Every plugin must be executable. +# +# SketchyBar fork_execs its plugins. When the bit is missing it reports NOTHING +# AT ALL — the item just never updates, which is indistinguishable from a script +# that runs and decides to do nothing. Editors and `git apply` both create files +# without it. +printf '\n== executable bits ==\n' +for f in "$PLUGINS"/*.sh; do + if [ -x "$f" ]; then note "ok $(basename "$f")" + else bad "$(basename "$f") is not executable — sketchybar will never run it"; fi +done + +# --------------------------------------------------------------- +# 2. Any item whose plugin can hide it must declare updates=on. +# +# Under the config-wide `updates=when_shown` default a hidden item stops being +# updated ENTIRELY: bar_item_update() gates timed and event runs behind +# `updates_only_when_shown ? is_shown : true`, and bar_draw() clears the bar +# association of anything it does not draw. So the item works right after a +# reload, hides itself when there is nothing to show, and never runs again. +# Eight items were found in that state at once. +printf '\n== self-hiding items declare updates=on ==\n' + +# ITEM-level drawing=off only. `label.drawing=off`, `background.drawing=off` and +# `popup.drawing=off` hide a COMPONENT and leave the item drawn and updating — +# amphetamine and pomodoro both dim their label this way and are correctly on the +# when_shown default. Requiring a preceding space excludes the dotted forms. +# Calling hide() or require() counts too: both end in an item-level drawing=off +# inside lib.sh. +can_hide() { + grep -qE '(^|[[:space:]])drawing=off' "$1" && return 0 + grep -qE '^[[:space:]]*(hide|require)([[:space:]]|$)|\|\|[[:space:]]*hide|&&[[:space:]]*hide' "$1" +} + +# Which items a plugin writes. Usually itself, but system.sh writes three. +items_for() { + case "$(basename "$1" .sh)" in + system) printf 'cpu memory thermals' ;; + workspaces) printf '' ;; # drives space.N; none of them ever hide + *) basename "$1" .sh ;; + esac +} + +for f in "$PLUGINS"/*.sh; do + can_hide "$f" || continue + for item in $(items_for "$f"); do + block="$(awk -v it="$item" ' + $0 ~ ("--add item[[:space:]]+" it "[[:space:]]") {found=1} + found {print} + found && /script=/ {exit}' "$RC")" + # PASSIVE ITEMS ARE NOT AT RISK. cpu and memory carry no script of their + # own — they are written by thermals' script, which has updates=on and + # therefore un-hides them on its next run. Only an item that must run its + # OWN script to recover can be trapped by when_shown. + if [ -z "$block" ]; then + bad "item '$item' hides itself but has no --add item block in sketchybarrc" + elif ! printf '%s' "$block" | grep -q 'script='; then + note "skip $item (passive — driven by another item's script)" + elif printf '%s' "$block" | grep -q 'updates=on'; then + note "ok $item" + else + bad "item '$item' can set drawing=off but does not declare updates=on" + fi + done +done + +# --------------------------------------------------------------- +# 3. Cross-references must resolve. +# +# The comments in this repo carry the measurements, so a comment that points at +# a section which no longer exists is a defect, not untidiness. Three were live +# at once: a deleted WIDTH BUDGET section, a privacy note that was never written, +# and a claim that an installed app was not installed. +printf '\n== cross-references resolve ==\n' +while IFS= read -r ref; do + [ -z "$ref" ] && continue + target="${ref#see the }"; target="${target% *}" + if grep -qF "$target" "$RC" "$PLUGINS"/*.sh 2>/dev/null; then + note "ok -> $target" + else + bad "dangling reference: '$ref' has no target" + fi +done < <(grep -ohE 'see the [A-Z][A-Z ]{3,}' "$RC" "$PLUGINS"/*.sh 2>/dev/null | sort -u) + +# --------------------------------------------------------------- +# 4. shellcheck, if available. +printf '\n== shellcheck ==\n' +if command -v shellcheck >/dev/null 2>&1; then + shellcheck -S warning -e SC1091 "$PLUGINS"/*.sh \ + sketchybar/.config/sketchybar/lib.sh || fail=1 + note "shellcheck done" +else + note "skip shellcheck not installed (brew install shellcheck)" +fi + +printf '\n' +if [ "$fail" -eq 0 ]; then printf 'PASS — no silent-failure patterns found\n' +else printf 'FAIL — see above\n'; fi +exit "$fail" From 6efb76b5726b8b666beb048143c35266063f04eb Mon Sep 17 00:00:00 2001 From: itsdanieldk Date: Mon, 3 Aug 2026 00:13:04 +0200 Subject: [PATCH 13/13] Update --- .gitignore | 1 + Brewfile | 2 + CLAUDE.md | 161 ++- aerospace/.config/aerospace/aerospace.toml | 224 +--- claude/.claude/settings.json | 4 + docs/audit-2026-08-02.md | 990 ------------------ scripts/lint-aerospace.sh | 108 ++ sketchybar/.config/sketchybar/colors.sh | 23 - .../.config/sketchybar/helpers/mic.swift | 35 - .../.config/sketchybar/helpers/thermal.swift | 47 - sketchybar/.config/sketchybar/lib.sh | 66 +- .../.config/sketchybar/plugins/amphetamine.sh | 5 - .../.config/sketchybar/plugins/battery.sh | 25 - .../.config/sketchybar/plugins/brightness.sh | 44 - .../.config/sketchybar/plugins/clock.sh | 2 +- .../.config/sketchybar/plugins/music.sh | 75 +- .../.config/sketchybar/plugins/pomodoro.sh | 4 - .../.config/sketchybar/plugins/system.sh | 48 - .../.config/sketchybar/plugins/volume.sh | 46 - sketchybar/.config/sketchybar/plugins/vpn.sh | 2 - .../.config/sketchybar/plugins/weather.sh | 16 - sketchybar/.config/sketchybar/plugins/wifi.sh | 5 - .../.config/sketchybar/plugins/workspaces.sh | 28 - sketchybar/.config/sketchybar/sketchybarrc | 861 +++------------ 24 files changed, 498 insertions(+), 2324 deletions(-) delete mode 100644 docs/audit-2026-08-02.md create mode 100755 scripts/lint-aerospace.sh diff --git a/.gitignore b/.gitignore index e33940e..333adb5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # Anchored with a leading slash so it matches only the repo root, never the # tracked `claude/` stow package. /.claude +CLAUDE.local.md # ============================================================ diff --git a/Brewfile b/Brewfile index 448c7db..d16bc3e 100644 --- a/Brewfile +++ b/Brewfile @@ -33,6 +33,7 @@ brew "eza" brew "fzf" brew "jq" brew "zoxide" +brew "media-control" # --- Utilities --- brew "fastfetch" @@ -48,6 +49,7 @@ brew "gh" brew "git-delta" brew "lazygit" brew "lazydocker" +brew "shellcheck" # --- Languages & runtimes --- brew "direnv" diff --git a/CLAUDE.md b/CLAUDE.md index a2f452b..ac1a562 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,64 +2,113 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Working agreement + +**Never run git operations** — no commits, no branches, no staging, no history rewriting — unless +explicitly asked in that message. Edit files and report; the user drives git. + ## What This Is -macOS dotfiles managed with GNU Stow. Each top-level directory (aerospace, bat, btop, claude, git, hushlogin, kitty, lazygit, nvim, ssh, zsh) is a stow package whose contents mirror the home directory structure (e.g. `zsh/.zshrc` symlinks to `~/.zshrc`). +macOS dotfiles managed with GNU Stow. Each top-level directory (`aerospace`, `bat`, `borders`, +`btop`, `claude`, `git`, `kitty`, `lazygit`, `nvim`, `raycast`, `sketchybar`, `ssh`, `zsh`) is a +stow package whose contents mirror `$HOME` (e.g. `zsh/.zshrc` → `~/.zshrc`). + +**Stow runs `--no-folding`** (set in `install` and `.stowrc`): directories are created for real and +only tracked leaf files are symlinked. This stops an app-managed directory (`~/.ssh`, `~/.claude`) +being folded into a symlink pointing *into the repo*, which would let runtime state and secrets get +written inside it. **Tradeoff: a NEW file in an existing package needs a re-stow before anything +sees it** — an edit to an existing file takes effect immediately, a new file does not. ## Commands -- Syntax-check the install script: `zsh -n install` -- Re-stow a single package: `stow -d ~/dotfiles --no-folding -R ` -- Run the full bootstrap: `./install` (interactive, prompts y/N for each step; use `./install --yes` for non-interactive, `./install --adopt` to allow stow conflict adoption) - -## Shell - -All scripts use **zsh** (not bash). The install script relies on zsh-specific builtins (`read -q`, `print`) and glob qualifiers (`*(/)` for directories). - -## Install Script - -`./install` is an interactive bootstrap with `set -eu`. It skips already-installed brew packages, catches individual failures with `|| warn`. Flags: `--yes`/`-y` for non-interactive mode, `--adopt` to opt into stow conflict adoption. - -- **Stow conflicts fail by default.** If `$HOME` already has files that would collide with the symlinks, the script errors out. To resolve, re-run with `--adopt` — but that's destructive toward the repo: stow moves the existing home-directory file *into the repo* (overwriting the tracked version) and symlinks it back. Always `git diff` after running with `--adopt` before committing. -- **Brewfile loop reads from FD 3** (`while ... <&3; done 3< Brewfile`) because `ask()`'s `read -q` consumes stdin. If you refactor the loop to a plain `< Brewfile`, every `ask` prompt eats the next Brewfile line and packages get silently skipped. -- **Brewfile parser handles `tap`, `brew`, `cask`** — all three must use the exact ` "pkg"` quoting; the regex (`^(brew|cask|tap)[[:space:]]+"([^"]+)"`) won't match anything else. -- **Third-party taps must be trusted, not just added.** Homebrew 6+ refuses to load formulae/casks from an untrusted tap (`Error: Refusing to load ... from untrusted tap`). Trust is separate state in `${XDG_CONFIG_HOME:-~/.config}/homebrew/trust.json`, falling back to `~/.homebrew/trust.json` (the script probes both, in that order), so `brew tap` alone is not enough and a tap can be present but unusable. The install script's tap branch runs `brew trust --tap` after tapping, and re-prompts for taps that are already added but untrusted. Keep the tap list minimal — each one is a third party trusted to run install code — and prefer homebrew-core when it carries the package. -- **`brew install` in the install script passes `--formula` deliberately.** Some taps ship a formula *and* a cask under the same name (`azure/azd` does). A bare `brew install` can resolve to the cask, which then never matches the `brew list --formula` skip-check, so the package re-prompts on every run. -- **.NET global tools live in the `install` script, not a `dotnet-tools.json`.** That filename is the *local* tool manifest format: it belongs inside a project, is created by `dotnet new tool-manifest`, and is consumed by `dotnet tool restore`. Global (`-g`) tools have no manifest at all — they install into `~/.dotnet/tools` (put on PATH by `.zprofile`). Don't "consolidate" the `dotnet_tools` array into a manifest file; they are different mechanisms with different invocation (`dotnet-ef` vs `dotnet ef`). Array entries are NuGet package IDs, which can differ from the command they provide (`dotnet-outdated-tool` → `dotnet-outdated`). -- **The hand-rolled Brewfile parser is deliberate, not an oversight.** `brew bundle` exists and would replace it, but it is all-or-nothing; the parser is what enables the per-package y/N prompt. Don't "simplify" it to `brew bundle` without dropping that feature knowingly. - -## Adding a New Stow Package - -Create a directory whose internal structure mirrors the home-relative path (e.g. `foo/.config/foo/config.toml`). The install script's stow loop picks it up automatically via the `*(/)` glob qualifier. - -## Architecture Notes - -- **Catppuccin Frappé** is the unified theme across kitty, nvim, bat, btop, lazygit, delta (git pager), and fzf (via `FZF_DEFAULT_OPTS` in `.zshrc`). When adding new tools with theme support, use Catppuccin Frappé for consistency. -- **Kitty theme** is extracted to `kitty/.config/kitty/themes/catppuccin-frappe.conf` via `include` — edit the theme file, not `kitty.conf`. -- **Git pager** is `delta` (not `less`). The `[delta]` section in `.gitconfig` is the single source of truth — lazygit invokes bare `delta`, which reads that section itself. lazygit's key is `git.pagers[].pager` (an *array* since 0.55; `git.paging` is the legacy form it migrates from). A bare `git.pager` is silently ignored, with no warning — verify changes actually took effect rather than trusting the config to be read. -- **SSH config is first-match-wins.** `Host *` must stay at the bottom of `ssh/.ssh/config`; a keyword set there can never be overridden by a `Host` block below it. `Include ~/.ssh/config.local` stays at the very top so machine-local overrides win (a missing include file is not an error). -- **AeroSpace binds keys system-wide**, ahead of kitty and zsh, and `alt` carries **two independent hazards** that are easy to conflate: - - **`alt-` collides with zsh widgets.** Upstream's `default-config.toml` binds `alt-` for 26 letter-named workspaces, which would swallow `^[c` (fzf-cd-widget), `^[b`/`^[f` (word motion), `^[d` (kill-word) and more. Hence *numeric* workspaces only. Before adding any `alt-` binding — in `aerospace.toml`, `kitty.conf`, or `.zshrc` — check it against `bindkey -M emacs` first. - - **`alt-` collides with the keyboard layout.** This machine is Danish, where `[ ] { } \` live on the Option layer of the digit row (`alt-8`, `alt-9`, `alt-shift-7/8/9`) with no other route. Binding those makes them untypable system-wide. **Workspaces are therefore on `ctrl-`, not `alt-`** — do not "tidy" them back toward upstream. `ctrl` is the escape and `cmd` is not: measured with `UCKeyTranslate`, `ctrl-8` → `8` but `cmd-alt-8` → `[`. Same applies to Norwegian/Swedish/Finnish/German layouts. - - Config lives at `aerospace/.config/aerospace/aerospace.toml`; a stray `~/.aerospace.toml` makes AeroSpace error on the ambiguity. -- **This machine is a SINGLE 2560×1440 external, and both AeroSpace and SketchyBar are tuned for exactly that.** There is no per-monitor `gaps`, no `workspace-to-monitor-force-assignment`, no `focus-monitor`/`move-node-to-monitor` binding, and no notch arithmetic or width budget in `sketchybarrc`. All of it existed for a laptop-plus-two-externals setup and was deliberately removed; the measurements are in git history. Don't reintroduce multi-monitor or notch assumptions without a second display actually being present. Note `kitty.conf` sets `macos_option_as_alt left`, so inside kitty the **left** Option key sends `ESC`-prefixed sequences while the **right** one composes layout characters — brackets in the terminal need the right Option key. -- **`.zprofile` opens with `typeset -U path PATH`, and that line is load-bearing.** This file appends to PATH unconditionally and every login shell re-sources it (a terminal login, kitty, and VS Code each add another copy), so without the unique-array flag entries accumulate. It must come before any PATH manipulation for later additions — including those in `.zshrc` — to be deduped. -- **The `Telemetry` block in `.zprofile` must stay ahead of the `Homebrew` block.** The Homebrew section runs `brew shellenv`, so `HOMEBREW_NO_ANALYTICS` has to already be exported by then. Variable names there were each taken from the tool itself rather than from memory (e.g. `AZURE_CORE_COLLECT_TELEMETRY` is what knack builds from `ENV_VAR_PREFIX='AZURE'`); verify against the tool before adding more. -- **gitignore has no trailing-comment syntax.** `#` only starts a comment at the start of a line, so `.ionide/ # cache` becomes part of the pattern and silently stops matching — no error, no warning. Keep comments on their own lines in both `.gitignore` and `git/.config/git/ignore`. Those two files are different scopes: the former covers this repo, the latter is stowed to `~/.config/git/ignore` and applies machine-wide. -- **Powerlevel10k** is the zsh prompt. Config lives in `zsh/.p10k.zsh`. The instant prompt block at the top of `.zshrc` must remain first — nothing can print to stdout before it. -- **Neovim** uses lazy.nvim for plugin management. Plugin specs live in `nvim/.config/nvim/lua/plugins/`. Core config (options, keymaps) lives in `nvim/.config/nvim/lua/config/`. There is deliberately no LSP (stripped in 539109d) — so nothing publishes diagnostics, and `blink.cmp` has no `lsp` source. Add diagnostic keymaps back only alongside something that produces them. -- **Any SketchyBar item whose script sets `drawing=off` on itself MUST also set `updates=on`.** This has been rediscovered three times and is the most expensive trap in the config. Under the config-wide `updates=when_shown` default a hidden item stops being updated *entirely* — `bar_item_update()` gates timed **and** event runs behind `updates_only_when_shown ? is_shown : true`, and `bar_draw()` clears the bar association of anything it does not draw. The item works right after a reload, hides itself when there is nothing to show, and then never runs again, silently. Eight items self-hide (`battery brightness bluetooth calendar weather github vpn mic`); `amphetamine` looks like one but only hides its *label*, so check for item-level `drawing=off` in the plugin before assuming. Note `updates=on` with `update_freq=0` is still event-only — it buys back recoverability without introducing polling. -- **New plugin scripts need `chmod +x`.** SketchyBar `fork_exec`s them and reports nothing at all when the bit is missing — the item simply never updates, which looks identical to a script that runs and does nothing. -- **A plugin runs with LESS access than your terminal, so test reads from a script SketchyBar actually runs.** The bar is launched by AeroSpace and has no Full Disk Access; a terminal usually does. Anything under `~/Library` that TCC protects will read fine when you try it by hand and fail with `PermissionError [Errno 1] Operation not permitted` in the plugin — with correct Unix permissions, so it does not look like a permissions problem. This is what killed a macOS Focus-mode indicator (`~/Library/DoNotDisturb/DB/Assertions.json`); see the note where it would have gone in `sketchybarrc`. -- **SketchyBar has two compiled Swift helpers** in `sketchybar/.config/sketchybar/helpers/` — `mic.swift` (is anything recording?) and `thermal.swift` (average SoC die temperature). Both are built up front by `install` *and* rebuilt on demand by their callers (`mic.sh`, `system.sh`) when the binary is missing or older than the source, so a `git pull` that changes one doesn't require re-running `install`. They are compiled rather than interpreted because they are polled: `swift` on a trivial script measured 1.25s. Both use private APIs deliberately — `thermal.swift` reads `IOHIDEventSystemClient*`, which is the only no-sudo route to the thermal sensors. Adding a new file to this package needs a re-stow before the running bar sees it (the `--no-folding` tradeoff below). -- **Don't trust `macmon`'s `temp.cpu_temp_avg`.** It is bimodal at flat idle — measured landing on either ~31.9 or ~38.1 and never between, dropping as far as 20°C while `cpu_power` sat at 0.06W. It is a mean over a varying sensor set, not a temperature. `thermal.swift` exists because of this; the full measurement is in its header. -- **`claude/.claude/statusline.sh` is vendored third-party code** ([daniel3303/ClaudeCodeStatusLine](https://github.com/daniel3303/ClaudeCodeStatusLine), see `VERSION` at the top). It reads OAuth credentials and makes network calls, so review diffs before pulling upstream changes. Local deviations from upstream: single-pass `jq` parsing, `$TMPDIR` cache dir, and the bearer token passed via `curl --config -` (never argv, which `ps` exposes). It must stay **bash 3.2**-compatible — `bash` resolves to `/bin/bash` on a machine without Homebrew's bash, which is not in the Brewfile. -- **Zsh load order** in `.zshrc` is critical and must be preserved: - 1. Powerlevel10k instant prompt (must be first — nothing can print to stdout before it) - 2. Oh My Zsh config + `source $ZSH/oh-my-zsh.sh` — any `fpath` additions (e.g. `$HOME/.docker/completions`) must come *before* the source line, since OMZ runs `compinit` during sourcing. In the `plugins=()` array, `fzf-tab` must come *before* `zsh-autosuggestions` and `zsh-syntax-highlighting` — fzf-tab wraps the completion widget and the syntax/autosuggestion plugins wrap the line editor; swapping their order silently breaks tab completion or kills syntax highlighting. - 3. Aliases and shell tool inits (fzf, zoxide, direnv) - 4. `source ~/.p10k.zsh` - 5. `setopt aliases` (required — p10k leaks `noaliases` from its config) -- **Brewfile format** uses `tap "pkg"`, `brew "pkg"`, or `cask "pkg"` — the install script's regex parser depends on this exact quoting. -- **Stow uses `--no-folding`** (set in `install` and `.stowrc`): target directories are created as real directories and only tracked leaf files are symlinked. This stops stow from folding an app-managed directory (`~/.ssh`, `~/.claude`, `~/.config/btop`) into a single symlink that points into the repo — which would otherwise let app runtime state and secrets (e.g. `~/.claude/.credentials.json`) get written *inside* the repo. Tradeoff: adding a *new* file to an existing package requires a re-stow to link it. `.gitignore` also defensively ignores everything under `ssh/.ssh/` and `claude/.claude/` except the tracked configs. +```sh +zsh -n install # syntax-check (install is zsh, not bash) +stow -d ~/dotfiles --no-folding -R # re-stow one package +./install [--yes] [--adopt] # bootstrap; interactive y/N per step +./scripts/lint-sketchybar.sh # SketchyBar silent-failure checks +./scripts/lint-aerospace.sh # AeroSpace cross-file coupling checks +``` + +`install` is **zsh**, not bash: it uses `read -q`, `print`, and the `*(/)` glob qualifier. + +`--adopt` is destructive toward the repo — stow moves the existing `$HOME` file *into* the repo, +overwriting the tracked version. Always diff after using it. + +## Hardware assumptions + +**Mac Studio (M4 Max), one 2560×1440 external.** No battery, no built-in display, no notch. +AeroSpace and SketchyBar are tuned for exactly this: no per-monitor gaps, no +`workspace-to-monitor-force-assignment`, no notch arithmetic. Don't reintroduce multi-monitor or +laptop assumptions without a second display actually present. + +## Keyboard — two independent hazards on `alt` + +- **`alt-` collides with the Danish layout.** `[ ] { } \` live on the Option layer of the + digit row (`alt-8`, `alt-9`, `alt-shift-7/8/9`) with no other route, so binding those makes them + untypable system-wide. **Workspaces are on `ctrl-` for this reason** — don't "tidy" them + back to upstream's `alt`. Measured with `UCKeyTranslate`: `ctrl-8` → `8`, but `cmd-alt-8` → `[`, + so `cmd` is *not* an escape. Same for Norwegian/Swedish/Finnish/German. +- **`alt-` collides with zsh widgets** (`^[b`/`^[f` word motion, `^[d` kill-word, …). + Check `bindkey -M emacs` before adding one. + +`kitty.conf` sets `macos_option_as_alt left`, so in the terminal the **right** Option key composes +layout characters and the left one sends `ESC` sequences. + +## SketchyBar — four traps, each rediscovered more than once + +All four fail *silently*: the bar comes up, an item is simply absent or stale, and nothing says why. +`./scripts/lint-sketchybar.sh` checks the first three. + +1. **An item whose script sets item-level `drawing=off` MUST also set `updates=on`.** Under the + config-wide `updates=when_shown`, a hidden item stops being updated entirely — so it works after + a reload, hides itself, and never runs again. `updates=on` with `update_freq=0` stays event-only, + so it costs no polling. Note `label.drawing=off` is *not* this — it hides a component, not the + item. +2. **New plugin scripts need `chmod +x`.** SketchyBar `fork_exec`s them and reports nothing when the + bit is missing. +3. **A plugin has less TCC access than your terminal.** The bar is launched by AeroSpace with no + Full Disk Access. Anything TCC-protected under `~/Library` reads fine by hand and fails with + `EPERM` in the plugin, at correct Unix permissions. **Test reads from a script the bar actually + runs**, not from a shell. +4. **Don't trust `macmon`'s `temp.cpu_temp_avg`.** Bimodal at flat idle — measured landing on ~31.9 + or ~38.1 and never between, dropping to 20 °C at 0.06 W. It is a mean over a varying sensor set. + `helpers/thermal.swift` exists because of this. + +Several properties are **not echoed by `sketchybar --query`** (`label.max_chars`, `blur_radius`, +`notch_*`), so a wrong value looks identical to a right one. Verify behaviour, not the query. + +## Install script gotchas + +- **The Brewfile loop reads from FD 3** (`... <&3; done 3< Brewfile`) because `ask()`'s `read -q` + consumes stdin. Convert it to a plain `< Brewfile` and every prompt eats the next line, silently + skipping packages. +- **`brew install` passes `--formula` deliberately** — some taps ship a formula *and* a cask under + one name, and the cask never matches the `brew list --formula` skip-check, so it re-prompts + forever. +- **Taps must be trusted, not just added** (Homebrew 6+). The script runs `brew trust --tap`; a tap + can be present but unusable. +- **The hand-rolled Brewfile parser is deliberate.** `brew bundle` would replace it but is + all-or-nothing; the parser is what enables the per-package y/N prompt. +- Brewfile format is exactly `tap "pkg"` / `brew "pkg"` / `cask "pkg"` — the regex depends on it. + +## `.zshrc` load order — must be preserved + +1. Powerlevel10k instant prompt **first**; nothing may print to stdout before it. +2. Oh My Zsh config, then `source $ZSH/oh-my-zsh.sh`. Any `fpath` additions go *before* the source + line (OMZ runs `compinit` during it). In `plugins=()`, **`fzf-tab` must precede + `zsh-autosuggestions` and `zsh-syntax-highlighting`** — fzf-tab wraps the completion widget while + the other two wrap the line editor; the wrong order silently breaks completion or highlighting. +3. Aliases and tool inits (fzf, zoxide, direnv) +4. `source ~/.p10k.zsh` +5. `setopt aliases` — required, p10k leaks `noaliases`. + +## Other + +- **Theme is Catppuccin Frappé** across kitty, nvim, bat, btop, lazygit, delta and fzf. Match it. +- **`claude/.claude/statusline.sh` is vendored** ([daniel3303/ClaudeCodeStatusLine]); it reads OAuth + credentials and makes network calls, so review diffs before pulling upstream. Must stay **bash + 3.2**-compatible. +- A stray `~/.aerospace.toml` makes AeroSpace error on the ambiguity — config lives only at + `aerospace/.config/aerospace/aerospace.toml`. + +[daniel3303/ClaudeCodeStatusLine]: https://github.com/daniel3303/ClaudeCodeStatusLine diff --git a/aerospace/.config/aerospace/aerospace.toml b/aerospace/.config/aerospace/aerospace.toml index 176cec0..8e987a9 100644 --- a/aerospace/.config/aerospace/aerospace.toml +++ b/aerospace/.config/aerospace/aerospace.toml @@ -6,103 +6,14 @@ # # AeroSpace reads either ~/.aerospace.toml or this XDG path. Having BOTH is an # error ("ambiguity is reported"), so ~/.aerospace.toml must not exist. -# -# WHY THIS DIFFERS FROM UPSTREAM'S default-config.toml: -# AeroSpace binds keys system-wide — it sees them before kitty or zsh do. The -# upstream default binds alt- for 26 letter-named workspaces, which would -# swallow this setup's shell keys: ^[c (fzf-cd-widget), ^[b / ^[f (word motion), -# ^[d (kill-word), ^[m, ^[n, ^[p, ^[g, ^[a. So: numeric workspaces only. -# Before adding any alt- binding here, check it against `bindkey -M emacs`. -# -# TWO SEPARATE HAZARDS LIVE ON alt, AND THEY ARE NOT THE SAME PROBLEM: -# -# alt- collides with ZSH WIDGETS (the paragraph above) -# alt- collides with the KEYBOARD LAYOUT (this paragraph) -# -# This machine is on the DANISH layout, where brackets and braces are on the -# Option layer of the digit row. Binding alt- here makes them untypable -# system-wide, with no alternative route on the layout: -# alt-8 [ alt-9 ] alt-shift-7 \ alt-shift-8 { alt-shift-9 } -# That is why workspace switching is on CTRL-, not alt-. It is not -# a style choice and it must not be "tidied" back to alt to match upstream. -# -# CTRL is the escape and CMD is not. Measured with UCKeyTranslate against the -# live input source, key 8: -# alt-8 -> [ alt-shift-8 -> { -# cmd-alt-8 -> [ <- Command does NOT suppress the Option layer -# ctrl-8 -> 8 ctrl-shift-8 -> 8 <- Control does -# ctrl-alt-8 -> 8 ctrl-alt-shift-8 -> 8 -# Same applies to Norwegian, Swedish, Finnish and German layouts. If this config -# is ever used on US ANSI the constraint disappears, but the bindings can stay. -# -# ctrl- and ctrl-shift- were checked and are owned by nothing here: -# not kitty.conf, not this file, not `bindkey -M emacs`. -# -# PUT CONTROL ON CAPS LOCK. Reaching the bottom-left Ctrl for a key pressed this -# often is the same stretch that ruled out alt-ctrl; Caps Lock is the best-placed -# key on the board and macOS remaps it natively, no software required: -# System Settings > Keyboard > Keyboard Shortcuts… > Modifier Keys -# -# THAT PANEL IS PER-KEYBOARD, which is the trap. macOS stores the mapping as -# `com.apple.keyboard.modifiermapping.--0`, so setting it on one -# device does nothing for another and the panel has a keyboard selector that is -# easy to miss. Two are attached here (from IOHIDManager): -# Keychron Link vendor 13364 product 53296 -# USB Receiver vendor 1133 product 50504 (Logitech) -# "I set it and it didn't work" almost always means it was set on the other one. -# -# IF THE NATIVE REMAP FEELS LAGGY, that is its known Caps Lock debounce, and it -# is the only good reason to escalate. Two escalations, in order of preference: -# 1. THE KEYBOARD'S OWN FIRMWARE. Many Keychron boards are QMK/VIA -# programmable and QMK has a native Hyper (LCTL+LSFT+LALT+LGUI). No Mac-side -# software, no driver, no permission grant, and it travels with the keyboard. -# Check the model against Keychron's VIA/Launcher support first. -# 2. Karabiner-Elements. Buys hyper (so ctrl+digit returns to apps) and no -# activation delay, at the cost of a DriverKit virtual HID driver, an Input -# Monitoring grant, and a karabiner.json that the app rewrites — awkward to -# stow for the same reason ~/.claude and ~/.ssh are (see CLAUDE.md). If it -# comes to that, stow a rule file under assets/complex_modifications/, which -# the app treats as read-only input, not karabiner.json itself. -# -# ONE THING TO WATCH: macOS Mission Control has "Switch to Desktop N" on ctrl-1..9 -# by default whenever more than one Space exists. System hotkeys are processed -# early and can beat AeroSpace, so keep to a single macOS Space or uncheck those -# in System Settings > Keyboard > Keyboard Shortcuts > Mission Control. -# -# Conscious losses to the shell, all low-value: -# ^[h run-help -> alt-h is 'focus left' -# ^[l OMZ ls widget -> alt-l is 'focus right' -# ^[F forward-word -> alt-shift-f is 'fullscreen' (^[f is the same widget) -# ^[1..^[9 (digit-argument) and ^[^H / ^[^L (backward-kill-word, clear-screen) -# used to be on that list and are RECOVERED — nothing binds alt- any more, -# and the alt-ctrl-shift-h/l monitor bindings went with the second monitor. -# -# INSIDE KITTY THE TWO OPTION KEYS DIFFER, and that is deliberate — kitty.conf -# sets `macos_option_as_alt left`. So: -# LEFT option + 8 -> ^[8, i.e. digit-argument (kitty sends it as Alt) -# RIGHT option + 8 -> [ (kitty lets the layout compose it) -# Use the RIGHT Option key for brackets and braces in the terminal. Everywhere -# else there is only one Option layer and either key composes them. -# -# SINGLE SCREEN. This config is tuned for one 2560x1440 external and nothing -# else: no per-monitor gaps, no workspace-to-monitor assignment, no -# focus-monitor or move-node-to-monitor bindings, and no notch arithmetic. If a -# second display or the laptop's own screen ever comes back, all of that has to -# be reintroduced — the measurements are in git history, not here. -# -# ON ANIMATIONS: there are none, and none are possible. AeroSpace repositions a -# window with a single Accessibility API write — there is no tween and no config -# option for one. (Upstream only discusses animation to explain why it replaced -# native macOS Spaces, whose switching animation can't be disabled.) The focus -# cue below is the substitute: JankyBorders draws a ring on the focused window, -# and that ring pulses on every focus change. - config-version = 2 # ============================================================ # Startup # ============================================================ +# What AeroSpace runs at launch and how it reloads this file. +# # The escape hatch is `aerospace enable toggle`, which suspends window management # without quitting — so there's no reason to keep launching it by hand. start-at-login = true @@ -117,8 +28,6 @@ auto-reload-config = true # ring's lifetime is tied to the window manager's: quit AeroSpace, no orphan. after-startup-command = [ 'exec-and-forget borders', - # The status bar. Both run with no arguments so they read their own stowed - # config (~/.config/borders/bordersrc, ~/.config/sketchybar/sketchybarrc). 'exec-and-forget sketchybar', ] on-mode-changed = [] @@ -127,11 +36,18 @@ on-mode-changed = [] # ============================================================ # Layout # ============================================================ +# How windows are arranged by default and the gaps left around them. + +# New windows tile rather than float or stack. default-root-container-layout = 'tiles' + # 'auto' = horizontal on wide monitors, vertical on tall ones default-root-container-orientation = 'auto' accordion-padding = 30 +# Keep the tree tidy as windows come and go: flatten drops containers left +# holding a single child, and opposite-orientation makes a nested container split +# the other way from its parent so new windows land predictably. enable-normalization-flatten-containers = true enable-normalization-opposite-orientation-for-nested-containers = true @@ -140,15 +56,9 @@ gaps.inner.horizontal = 8 gaps.inner.vertical = 8 gaps.outer.left = 8 gaps.outer.bottom = 8 -# 43 CLEARS SKETCHYBAR, and the two must move together. The islands are 32pt -# tall centred in a 38pt bar, so their bottom edge is at y=35; 43 = 35 + the -# usual 8pt gap. The bar draws inside the area AeroSpace tiles into, so without -# this windows slide underneath it. Retune the bar height or the island height -# and this number is wrong. -# -# Also paired with the macOS menu bar being auto-hidden (see the install script): -# with the menu bar visible this would sit on top of its 30, and the bar would -# land below it rather than at the top of the screen. +# 43 = SketchyBar's height (38, set in sketchybarrc) + 5pt clearance. These two +# numbers are coupled: raise the bar and windows tuck underneath it, lower the +# bar and a band of wallpaper opens above them. gaps.outer.top = 43 gaps.outer.right = 8 @@ -156,6 +66,9 @@ gaps.outer.right = 8 # ============================================================ # Behaviour # ============================================================ +# Focus feedback, the bridge that keeps SketchyBar in sync, and a few +# interaction defaults. +# # Focus cue. AeroSpace can't animate a window move (see the note at the top), so # the JankyBorders ring flashes lavender for ~120ms and settles back to mauve. # Re-invoking `borders` reconfigures the running instance rather than starting a @@ -164,10 +77,6 @@ gaps.outer.right = 8 # kitty uses for active_tab_background). They must stay in sync with bordersrc. # If a held-down alt-h/alt-l ever makes the pulse look like flicker, drop this # callback — the static ring survives on its own. -# -# Deliberately NOT 'move-mouse window-lazy-center': the pointer stays put on -# focus changes. (There was an on-focused-monitor-changed callback here too; it -# can never fire with one screen.) on-focus-changed = [ 'exec-and-forget /bin/sh -c "borders active_color=0xffbabbf1; sleep 0.12; borders active_color=0xffca9ee6"', ] @@ -180,6 +89,8 @@ exec-on-workspace-change = [ '/bin/bash', '-c', 'sketchybar --trigger aerospace_workspace_change FOCUSED_WORKSPACE=$AEROSPACE_FOCUSED_WORKSPACE', ] +# Focus only on keypress (not on hover), leave macOS-hidden apps hidden, and +# read the keyboard as QWERTY regardless of the active layout. focus-follows-mouse.enabled = false automatically-unhide-macos-hidden-apps = false key-mapping.preset = 'qwerty' @@ -202,83 +113,38 @@ persistent-workspaces = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] # Bundle IDs were read off each app's Info.plist, not guessed. To add one: # `aerospace list-apps` while it's running. # -# Deliberately NOT here: browsers, kitty, VS Code. This fires for *every* new -# window, so a placement rule on those would yank a window you just opened onto -# another monitor mid-task. on-window-detected = [ # Float — utilities and preference panes; a tile only distorts the layout. { if = 'test %{app-bundle-id} = com.apple.systempreferences', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.apple.calculator', run = 'layout floating' }, - { if = 'test %{app-bundle-id} = com.fasttracksoftware.adminbyrequest', run = 'layout floating' }, { if = 'test %{app-bundle-id} = app.monitorcontrol.MonitorControl', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.logi.optionsplus', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.crystalidea.macsfancontrol', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.focusrite.control', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.titanium.OnyX', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.jetbrains.toolbox', run = 'layout floating' }, - # Raycast — the launcher panel itself is a non-activating window AeroSpace - # already ignores, but its Settings window tiles. This was commented out for - # a long time on the belief that Raycast "is in the Brewfile but not - # installed here"; that had become false — /Applications/Raycast.app exists - # and its Info.plist gives the id below. Enabled. - # Caveat, same as the two rules further down: the id came from Info.plist, - # not from `aerospace list-apps` with the Settings window open, because - # Raycast was not running. If the rule never fires, that is the first thing - # to re-check — a wrong id does not error, it just never matches. { if = 'test %{app-bundle-id} = com.raycast.macos', run = 'layout floating' }, - - # Float — games and compatibility layers. Different reason from the block - # above: these aren't utilities, they just tile badly. Game windows expect to - # own their geometry, and a tiling WM resizing them mid-session ranges from - # letterboxing to a wedged renderer. { if = 'test %{app-bundle-id} = com.codeweavers.CrossOver', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.valvesoftware.steam', run = 'layout floating' }, - # - # THESE TWO COVER THE LAUNCHERS, NOT THE GAMES. CrossOver is at - # ~/Applications/CrossOver.app (not /Applications), and every bottle gets its - # own generated launcher with a HASHED bundle id, e.g. measured here: - # ~/Applications/CrossOver/Steam/Steam.app - # com.codeweavers.CrossOverHelper.4DB4563826BAD0EB2F60EE6E42D0EA4B.D7B4… - # ~/Applications/CrossOver/Battle.net/Battle.net.app - # com.codeweavers.CrossOverHelper.747D99F92EE9C080BA26108AC5D26488.4C9D… - # So a window from a bottle does NOT match the CrossOver rule above, and - # pasting the hashes in would break the next time a bottle is created. If - # bottle windows need floating, run `aerospace list-apps` WITH THE GAME OPEN - # and add what it actually reports — per the "read it, don't guess" rule - # above. Both ids here came from each app's Info.plist; neither app was - # running to confirm against list-apps. - - # Place — chat/mail/notes open on their home screens (see the map below). { if = 'test %{app-bundle-id} = com.microsoft.teams2', run = 'move-node-to-workspace 6' }, { if = 'test %{app-bundle-id} = com.microsoft.Outlook', run = 'move-node-to-workspace 7' }, { if = 'test %{app-bundle-id} = md.obsidian', run = 'move-node-to-workspace 8' }, ] -# There is no [workspace-to-monitor-force-assignment] section, deliberately — -# with one screen every workspace lands there anyway. The app-placement rules -# above still hold: Teams/Outlook/Obsidian open on workspaces 6/7/8 so they are -# out of the way, which is now about workspace hygiene rather than which screen -# they appear on. - # ============================================================ # Main bindings # ============================================================ +# Everyday window management, all on the `alt` modifier. Note that every +# alt- here also shadows a zsh line-editor widget (^[b/^[f word motion, +# ^[d kill-word, …), so check `bindkey -M emacs` before adding a new one — see +# CLAUDE.md. [mode.main.binding] - # Launch a terminal, i3/sway style. Deliberately alt-SHIFT-enter and not the - # idiomatic alt-enter: AeroSpace binds system-wide, ahead of kitty and zsh, - # so alt-enter would permanently swallow ^[^M — which is self-insert-unmeta - # in zsh's emacs keymap and "insert newline without submitting" in Claude - # Code. Neither can be won back per-app once AeroSpace has the key. - # `open -na` for a new instance each time; kitty has no single-instance or - # remote-control setup here for a new window to attach to. + # Launch a terminal, then focus by direction. Focus wraps at the screen edge + # (cheap to undo), unlike the moves further down which deliberately stop. alt-shift-enter = 'exec-and-forget open -na kitty' - # Focus. --wrap-around because on one screen the alternative is a dead key: - # with two windows side by side, alt-l from the right-hand one used to do - # nothing at all, which reads as broken rather than as "no window there". - # Wrapping makes every press move focus somewhere. alt-h = 'focus --wrap-around left' alt-j = 'focus --wrap-around down' alt-k = 'focus --wrap-around up' @@ -299,12 +165,13 @@ on-window-detected = [ # Layout alt-slash = 'layout tiles horizontal vertical' alt-comma = 'layout accordion horizontal vertical' - # ^[F is bound to forward-word, but so is ^[f — this costs a duplicate. - alt-shift-f = 'fullscreen' - # Workspaces. CTRL, NOT ALT — alt- is where the Danish layout keeps - # [ ] { } \ and binding it here makes them untypable system-wide. See the - # two-hazards note at the top of this file before changing this. + # ctrl, NOT upstream's alt — do not "fix" this back. On the Danish layout + # `[ ] { } \` live on the Option layer of the digit row (alt-8, alt-9, + # alt-shift-7/8/9) with no other route to them, so binding alt- makes + # those characters untypable system-wide, in every app. Measured with + # UCKeyTranslate: ctrl-8 gives 8, but cmd-alt-8 still gives [ — cmd is not an + # escape hatch. Same trap on Norwegian, Swedish, Finnish and German. ctrl-1 = 'workspace 1' ctrl-2 = 'workspace 2' ctrl-3 = 'workspace 3' @@ -336,22 +203,31 @@ on-window-detected = [ # (backward-kill-word) and ^[^L (clear-screen) back to the shell, and leaves # three easy chords free if something else wants them. - alt-shift-semicolon = 'mode service' + alt-shift-semicolon = ['mode service', 'exec-and-forget sketchybar --set aerospace_mode drawing=on'] # ============================================================ # Service mode # ============================================================ # Entered with alt-shift-semicolon; every binding returns to main mode. +# +# THE SERVICE PILL. Entering the mode shows a "SERVICE" pill (island.mode in +# sketchybarrc); the trailing `… drawing=off` on every binding hides it again on the +# way back to main. This is manual on purpose: on-mode-changed carries no mode name +# (AeroSpace issue #390 — `aerospace list-exec-env-vars` shows no AEROSPACE_MODE), so +# no single callback can tell the bar "service" from "main". CONSEQUENCE: a NEW binding +# added here MUST carry the same hide, or the pill sticks on SERVICE after that key. +# Harmless while sketchybar isn't running — the --set just fails into the void. [mode.service.binding] - esc = ['reload-config', 'mode main'] - r = ['flatten-workspace-tree', 'mode main'] # reset layout - f = ['layout floating tiling', 'mode main'] # toggle float/tile - b = ['balance-sizes', 'mode main'] # equalise all windows - s = ['swap --swap-focus dfs-next', 'mode main'] # swap with next window - backspace = ['close-all-windows-but-current', 'mode main'] - - alt-shift-h = ['join-with left', 'mode main'] - alt-shift-j = ['join-with down', 'mode main'] - alt-shift-k = ['join-with up', 'mode main'] - alt-shift-l = ['join-with right', 'mode main'] + esc = ['reload-config', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # exit + reload + r = ['flatten-workspace-tree', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # reset layout + f = ['layout floating tiling', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # toggle float/tile + m = ['fullscreen', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # toggle fullscreen + b = ['balance-sizes', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # equalise windows + s = ['swap --swap-focus dfs-next', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # swap with next + backspace = ['close-all-windows-but-current', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # close others + + alt-shift-h = ['join-with left', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] + alt-shift-j = ['join-with down', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] + alt-shift-k = ['join-with up', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] + alt-shift-l = ['join-with right', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] diff --git a/claude/.claude/settings.json b/claude/.claude/settings.json index d00bfc7..d1137ad 100644 --- a/claude/.claude/settings.json +++ b/claude/.claude/settings.json @@ -4,6 +4,10 @@ "type": "command", "command": "bash ~/.claude/statusline.sh" }, + "enabledPlugins": { + "playwright@claude-plugins-official": true, + "skill-creator@claude-plugins-official": true + }, "effortLevel": "xhigh", "tui": "fullscreen", "theme": "auto", diff --git a/docs/audit-2026-08-02.md b/docs/audit-2026-08-02.md deleted file mode 100644 index 94354ef..0000000 --- a/docs/audit-2026-08-02.md +++ /dev/null @@ -1,990 +0,0 @@ -# Production Readiness Audit — AeroSpace / SketchyBar Desktop Environment - -**Repository:** `dl/dotfiles` -**Audit date:** 2026-08-02 -**Scope:** AeroSpace, SketchyBar, all shell plugins, repository architecture, portability -**Method:** static review of all 21 SketchyBar files and `aerospace.toml`, plus runtime -measurement on the live host (single 2560×1440 external, Apple Silicon, macOS 26, Danish layout) - -> **Evidence convention.** Every performance number in this report was measured on the audited -> host and the command is given. Claims that were *not* verified on hardware are labelled -> **[INFERRED]**. Anything labelled **[SELF-INFLICTED]** was introduced by recent work in this -> repository and is reported without softening. - ---- - -## 1. Executive Summary - -This is an unusually well-documented personal desktop configuration with a genuinely high standard -of recorded reasoning — measurements are captured in comments, trade-offs are argued rather than -asserted, and several non-obvious platform behaviours (SketchyBar's group geometry, CoreText ink -metrics, TCC boundaries) are documented at a level most production codebases never reach. - -It is also **not production ready as an open-source artefact**, for reasons that are mostly -structural rather than functional. The configuration works well day-to-day. What it lacks is the -ability to survive contact with a second machine, a second maintainer, or six months of neglect. - -The three findings that matter most: - -1. **The bar consumes 8.6% of one CPU core, continuously, to draw a status bar.** Measured: - 5,181 ms of CPU per minute. Three plugins account for 86% of it, and the worst offender polls - a 63 ms helper every 2 seconds to answer a question that has an event-driven API. -2. **The entire working tree is uncommitted**, including a file (`helpers/thermal.swift`) that two - other components depend on and that is not tracked by git at all. A fresh clone silently - degrades rather than failing. -3. **The configuration has a systemic silent-failure culture.** Four distinct classes of - "fails quietly and looks identical to working" were found. Three were fixed recently; the - pattern that produced them is not yet encoded anywhere enforceable. - -The dominant maintainability risk is not complexity — it is that **the documentation is load-bearing -and is already drifting from reality**. Three comments were found asserting things that are -measurably false. In a config where comments carry the measurements, a false comment is a defect. - ---- - -## 2. Scores - -| Dimension | Score | Justification | -|---|---:|---| -| **Production readiness** | **6 / 10** | Functions reliably in daily use; blocked from higher by uncommitted state, untracked dependency, and no test or CI of any kind. | -| **Architecture** | **5 / 10** | 951-line monolithic `sketchybarrc`; zero shared code across 17 plugins; `hide()` redefined 3×, truncation idiom copy-pasted 3×, a 6-line `osascript` block duplicated verbatim within one file. | -| **Performance** | **4 / 10** | 8.6% of one core at idle, measured. `mic` alone is 1,890 ms/min. Three plugins poll where an event exists. No caching of invariant lookups. | -| **Maintainability** | **7 / 10** | Exceptional comment quality and rationale capture — the single strongest attribute. Penalised for monolith size and for three provably-false comments. | -| **Reliability** | **6 / 10** | Four silent-failure classes identified; three fixed. Failure modes are consistently *quiet*, which is the worst property a status bar can have. | -| **Security** | **7 / 10** | No secrets in repo; `.gitignore` defensively excludes runtime state; `--no-folding` deliberately prevents app state landing in the repo. Penalised for unauthenticated IP-geolocating network call and a vendored credential-reading script. | -| **Portability** | **4 / 10** | Hardcoded `/opt/homebrew` (breaks on Intel); hard dependency on Danish layout, Apple Silicon, single screen, and eight third-party binaries with no capability detection. | - -**Weighted overall: 5.6 / 10** — a strong personal configuration, a weak open-source project. - ---- - -## 3. Critical Findings - -### GIT-001 — Untracked file is a hard dependency of two components - -| | | -|---|---| -| **Category** | Reliability / Build integrity | -| **Severity** | **Critical** | -| **Files** | `sketchybar/.config/sketchybar/helpers/thermal.swift` (untracked), `install`, `sketchybar/.config/sketchybar/plugins/system.sh` | -| **Effort** | 5 min | -| **Depends on** | Nothing | - -**Description.** `helpers/thermal.swift` is not tracked by git. `install` builds it and `system.sh` -both builds and executes it. - -**Root cause.** The file was created and never `git add`ed. Both consumers guard defensively — -`install` uses `[[ -r $thermal_src ]]` and `system.sh` checks `[ -r "$SRC" ]` — so both **skip -silently** when it is absent. - -**Impact.** A fresh clone produces a bar with no temperature reading and **no error**. The -`install` script prints no warning because its guard fails before the `warn` branch is reachable. -This is the exact silent-degradation pattern the rest of this report criticises, in the build path. - -**Recommended implementation.** -```bash -git add sketchybar/.config/sketchybar/helpers/thermal.swift -``` -Then harden the guard so absence is loud rather than silent: -```bash -# install — distinguish "no swiftc" from "source missing" -thermal_src="${HOME}/.config/sketchybar/helpers/thermal.swift" -if [[ ! -r $thermal_src ]]; then - warn "thermal helper source missing at $thermal_src — temperature will be omitted." -elif ! command -v swiftc &>/dev/null; then - warn "swiftc not found — thermal helper not built; temperature will be omitted." -elif swiftc -O -o "${thermal_src:h}/thermal" "$thermal_src" 2>/dev/null; then - ok "sketchybar thermal helper built." -else - warn "sketchybar thermal helper failed to build; temperature will be omitted." -fi -``` - -**Edge cases.** The same three-way guard applies to `mic.swift`; fix both for symmetry. - -**Risks.** None. - -**Acceptance criteria.** -1. `git ls-files sketchybar/.config/sketchybar/helpers/` lists both `.swift` files. -2. `git stash -u && ./install` emits an explicit warning naming the missing file. -3. Fresh clone → `./install` → `sketchybar --query thermals` shows a temperature. - ---- - -### GIT-002 — Entire working tree uncommitted - -| | | -|---|---| -| **Category** | Process | -| **Severity** | **Critical** | -| **Files** | 10 modified, 2 untracked | -| **Effort** | 15 min | -| **Depends on** | GIT-001 | - -**Description.** `git status` shows 10 modified files and 2 untracked. Last commit is 23 hours old. - -**Impact.** Substantial recent work — the `updates=on` reliability sweep across 9 items, the -thermal helper, the Danish keyboard remediation, the single-screen strip — exists only in the -working tree. Any `git checkout`, failed merge, or disk event destroys it. There is no upstream -divergence (`0` commits ahead), so nothing is backed up remotely either. - -**Recommended implementation.** Commit in coherent units rather than one blob, so the history stays -bisectable: - -```bash -git add sketchybar/.config/sketchybar/helpers/thermal.swift \ - sketchybar/.config/sketchybar/plugins/system.sh -git commit -m "sketchybar: read die temperature from a helper, not macmon - -macmon's temp.cpu_temp_avg is bimodal at flat idle — measured landing on -either ~31.9 or ~38.1 and dropping to 20C while cpu_power sat at 0.06W. -It is a mean over a varying sensor set. thermal.swift reads PMU tdie* -directly via IOHIDEventSystemClient; spread 0.30C over 20 reads." - -git add sketchybar/.config/sketchybar/sketchybarrc -git commit -m "sketchybar: updates=on for every self-hiding item - -A hidden item under the config-wide when_shown default stops being -updated entirely, so it can never un-hide. Eight items were affected." - -# ... aerospace, docs, etc. -``` - -**Acceptance criteria.** `git status --short` is empty; `git log --oneline -8` shows discrete, -self-describing commits. - ---- - -## 4. High Findings - -### PERF-001 — `mic` polling costs 1,890 ms/min; the helper is 8× slower than documented - -| | | -|---|---| -| **Category** | Performance | -| **Severity** | **High** | -| **Files** | `sketchybar/.config/sketchybar/helpers/mic.swift:17`, `sketchybarrc:745`, `plugins/mic.sh` | -| **Effort** | 20 min (interval change) / 3 h (event-driven rewrite) | -| **Depends on** | Nothing | - -**Description.** The `mic` item runs a compiled helper every 2 seconds. Measured across 10 -consecutive runs: **min 60 ms, median 63 ms, max 65 ms**. At `update_freq=2` that is -**1,890 ms of CPU per minute** — 36% of the bar's entire budget and ~3% of one core, continuously. - -**Root cause.** Two compounding errors. - -1. `mic.swift:17` asserts *"Compiled it runs in single-digit ms."* This is **false by a factor of - 8**. The claim likely measured process startup delta rather than end-to-end wall time. The 2 s - interval was chosen *because* of that false premise — the comment reads "The 2s poll is - affordable because the helper is a compiled binary". -2. The cost is inherent to the approach: the helper enumerates **all** CoreAudio devices, queries - `kAudioDevicePropertyStreamConfiguration` per device to identify inputs, then queries - `kAudioDevicePropertyDeviceIsRunningSomewhere` per input. That is O(devices) IPC round-trips - into `coreaudiod` on every tick. - -**Reproduce:** -```bash -python3 -c " -import subprocess, time -ts=[] -for _ in range(10): - s=time.time(); subprocess.run(['$HOME/.config/sketchybar/helpers/mic'],capture_output=True) - ts.append((time.time()-s)*1000) -print(f'min={min(ts):.0f} median={sorted(ts)[5]:.0f} max={max(ts):.0f}')" -``` - -**Recommended implementation — two tiers.** - -*Tier 1 (20 min, recommended first).* Correct the comment and raise the interval. A recording -indicator does not need 2-second latency: -``` -update_freq=5 # 1890 -> 756 ms/min, a 60% reduction -``` -And in `mic.swift`, replace the false claim: -```swift -// WHY IT IS COMPILED: `/usr/bin/swift` interpreting even a trivial script measured -// 1.25s, which is not pollable. Compiled it runs in ~63ms (median of 10 runs) — -// NOT single-digit ms, as this comment previously claimed. The cost is inherent: -// enumerating CoreAudio devices and querying two properties per device is O(devices) -// IPC into coreaudiod. That 63ms is why update_freq is 5 and not 2; at 2s it was -// 1890ms/min, the single most expensive thing in this config. -``` - -*Tier 2 (3 h, the correct fix).* Make it event-driven. CoreAudio supports property listeners on -`kAudioDevicePropertyDeviceIsRunningSomewhere`. Convert `mic` from a polled helper to a resident -listener that pushes a custom SketchyBar event: -```swift -// Resident daemon; on change: system("sketchybar --trigger mic_changed") -AudioObjectAddPropertyListener(deviceID, &addr, handler, nil) -``` -`sketchybarrc` then becomes: -``` ---add event mic_changed ---add item mic right ---subscribe mic mic_changed ---set mic updates=on update_freq=0 script="$PLUGIN_DIR/mic.sh" -``` -This drops the cost to **0 ms/min** at idle. Precedent exists: `volume` and `wifi` already ride -native events and never poll. - -**Edge cases.** A resident daemon needs a lifecycle owner. Start it from `aerospace.toml`'s -`after-startup-command` alongside `borders` and `sketchybar` so its lifetime matches theirs — do -**not** use `brew services`, per the existing documented rationale. - -**Risks.** Tier 2 introduces a long-lived process; a crash silently stops updates. Mitigate by -having `mic.sh` fall back to a one-shot read if the daemon is absent. - -**Acceptance criteria.** -1. Comment states the measured figure. -2. Tier 1: `update_freq=5`; recompute → ≤ 760 ms/min. -3. Tier 2: `mic` shows `update_freq=0`; starting a recording lights the item within 1 s. - ---- - -### PERF-002 — `system` plugin costs 1,860 ms/min, dominated by a 854 ms `macmon` call - -| | | -|---|---| -| **Category** | Performance | -| **Severity** | **High** | -| **Files** | `plugins/system.sh:26`, `sketchybarrc` (thermals item) | -| **Effort** | 45 min | -| **Depends on** | Nothing | - -**Description.** `system.sh` runs `macmon pipe -s 1 -i 100` every 30 s. Measured: **854 ms**. -Plus the thermal helper at 76 ms → **930 ms per invocation, 1,860 ms/min** (36% of budget). - -**Root cause.** `macmon` samples a full SoC telemetry frame — power, per-cluster frequencies, ANE, -GPU — to extract three values: CPU %, RAM used, fan RPM. The `-i 100` interval sets a 100 ms -sampling window, but the observed 854 ms is dominated by process startup and IOReport subscription -setup, not the window. - -**Recommended implementation.** Replace `macmon` with direct reads. All three values are available -far more cheaply: - -- **RAM** — `host_statistics64` VM stats. Instantaneous, no sampling window. -- **CPU %** — `host_processor_info` deltas. Needs two samples; the existing `thermal.swift` helper - can hold state between invocations if converted to a short-lived daemon, or sample over ~100 ms. -- **Fan RPM** — the blocker. **[INFERRED]** — I scanned Apple's HID sensor usage page and found - only usage 5 (temperature, 39 services) and usage 1; no fan service was exposed. Fan RPM likely - requires SMC access. **Verify before committing to this path:** if fan RPM is not reachable - without `macmon`, keep `macmon` solely for that and drop its interval to 60 s. - -Interim change (5 min, no investigation needed): -``` -update_freq=60 # 1860 -> 930 ms/min -``` -CPU/RAM/fan at 30 s resolution is not information anyone acts on faster than 60 s. - -**Edge cases.** `system.sh` writes three items from one sample by design (documented). Any -replacement must preserve that — splitting into three polled items would triple the cost. - -**Acceptance criteria.** `time bash plugins/system.sh` ≤ 200 ms, **or** `update_freq=60` with the -one-sample-three-items property intact and `cpu`/`memory`/`thermals` all still populating. - ---- - -### PERF-003 — `aerospace.sh` spawns 9 subprocesses per event, twice per event class - -| | | -|---|---| -| **Category** | Performance / Architecture | -| **Severity** | **High** | -| **Files** | `plugins/aerospace.sh:19`, `sketchybarrc:262` | -| **Effort** | 40 min | -| **Depends on** | Nothing | - -**Description.** Each of 9 workspace items runs `aerospace.sh`, and each invocation shells out to -`aerospace list-workspaces --monitor all --empty no`. Measured: **25 ms each, 104 ms for 9 -sequential calls**. - -Worse, `sketchybarrc:262` subscribes every workspace item to **both** -`aerospace_workspace_change` **and** `front_app_switched`: -``` ---subscribe "space.$sid" aerospace_workspace_change front_app_switched -``` -So every application switch — a very high-frequency user action — triggers 9 `aerospace` CLI -invocations plus 9 `grep` subprocesses. - -**Root cause.** Each item independently re-derives global state (which workspaces are occupied) -that is identical across all nine. - -**Recommended implementation.** Compute once, distribute to all items in a single `sketchybar` -call. Replace per-item `aerospace.sh` with one script driven by a single item: - -```bash -#!/usr/bin/env bash -# workspaces.sh — repaints ALL workspace pills from ONE query. -source "$HOME/.config/sketchybar/colors.sh" - -focused="${FOCUSED_WORKSPACE:-$(aerospace list-workspaces --focused 2>/dev/null)}" -occupied=" $(aerospace list-workspaces --empty no 2>/dev/null | tr '\n' ' ')" - -args=() -for sid in $(aerospace list-workspaces --all 2>/dev/null); do - if [ "$sid" = "$focused" ]; then - args+=(--set "space.$sid" background.drawing=on label.color="$CRUST" icon.color="$CRUST") - elif [ "${occupied#* $sid }" != "$occupied" ]; then - args+=(--set "space.$sid" background.drawing=off label.color="$TEXT" icon.color="$TEXT") - else - args+=(--set "space.$sid" background.drawing=off label.color="$SURFACE2" icon.color="$SURFACE2") - fi -done -sketchybar "${args[@]}" -``` - -This is **2 `aerospace` calls and 1 `sketchybar` call total** instead of 9 + 9 + 9. It also removes -the `grep -qx` subprocess per item via parameter expansion. - -Also drop `--monitor all` (meaningless on one screen) and reconsider the `front_app_switched` -subscription — it exists so a newly-opened window marks its workspace occupied, but `front_app_switched` -is the wrong signal for that; window creation is already covered by `aerospace_workspace_change` -when the window lands. **[INFERRED]** — verify by opening a window in an empty workspace and -confirming the pill lights without the subscription. - -**Edge cases.** The `${occupied#* $sid }` test requires the sentinel spaces added above; without -them workspace `1` would match inside `10`. This config has single-digit workspaces only, but the -guard costs nothing and prevents a real bug if workspaces ever reach double digits. - -**Acceptance criteria.** -1. One `aerospace_workspace_change` produces ≤ 3 subprocesses (verify with `fs_usage` or by timing). -2. Switching workspaces still repaints all 9 pills correctly, including the mauve focused pill. -3. Total repaint latency < 40 ms. - ---- - -### DOC-001 — Three comments assert things that are measurably false - -| | | -|---|---| -| **Category** | Maintainability | -| **Severity** | **High** | -| **Files** | `plugins/system.sh:76` **[SELF-INFLICTED]**, `sketchybarrc:712`, `aerospace.toml:221` | -| **Effort** | 10 min | -| **Depends on** | Nothing | - -**Description.** In a configuration whose comments carry the measurements, a false comment is a -defect of the same class as a false assertion in code. Three were found: - -| Location | Claim | Reality | -|---|---|---| -| `system.sh:76` | *"see the WIDTH BUDGET in sketchybarrc"* | `grep -c 'WIDTH BUDGET' sketchybarrc` → **0**. Deleted during the single-screen strip. **[SELF-INFLICTED]** | -| `sketchybarrc:712` | *"see the privacy note in weather.sh"* | `grep -ci privacy weather.sh` → **0**. The note never existed. | -| `aerospace.toml:221` | *"Raycast is in the Brewfile but not installed here"* | `/Applications/Raycast.app` **is installed**; bundle id `com.raycast.macos` — exactly the value the comment declined to guess. | - -**Impact.** Each sends a future maintainer to a section that does not exist, or encodes a false -premise. The third actively blocks a working feature: the Raycast floating rule is commented out -awaiting a bundle ID that is now trivially obtainable. - -**Recommended implementation.** - -`system.sh:76` — the width justification no longer applies on a single screen: -```bash -# The helper prints one decimal — precise enough to watch the die move while -# debugging, but the bar shows a whole number. A tenth of a degree is not -# information anyone acts on, and it keeps the pill one character narrower. -``` - -`sketchybarrc:712` — either write the note or drop the reference. Write it (see SEC-001): -```bash -# Current conditions. 900s because weather does not change faster than that and -# every poll is an unauthenticated request to a third party that geolocates by -# source IP — see the privacy note in weather.sh. -``` - -`aerospace.toml:221` — verify and enable: -```toml -{ if = 'test %{app-bundle-id} = com.raycast.macos', run = 'layout floating' }, -``` -Confirm first with Raycast Settings open: `aerospace list-apps | grep -i raycast`. - -**Acceptance criteria.** Every `see ` reference in both configs resolves: -```bash -grep -oE 'see [a-z_]+\.(sh|swift|toml)|see the [A-Z][A-Z ]+' sketchybarrc aerospace.toml -# every target must exist -``` - ---- - -### SB-001 — Silent-failure patterns are documented but not enforced - -| | | -|---|---| -| **Category** | Reliability | -| **Severity** | **High** | -| **Files** | `CLAUDE.md`, all plugins, `install` | -| **Effort** | 1.5 h | -| **Depends on** | Nothing | - -**Description.** Four classes of failure that are invisible when they occur were identified. Three -have been fixed reactively; nothing prevents recurrence. - -| Class | Mechanism | Status | -|---|---|---| -| Self-hiding item cannot recover | `updates_only_when_shown ? is_shown : true` gates timed **and** event runs; `bar_draw` clears bar association of undrawn items. Item works after reload, hides, never runs again. | Fixed on 9 items | -| Missing execute bit | SketchyBar `fork_exec`s plugins and reports nothing when non-executable. Item never updates — identical to a script that runs and does nothing. | Fixed once | -| Plugin TCC ≠ terminal TCC | The bar has no Full Disk Access; a terminal usually does. `~/Library` reads succeed by hand and fail in-plugin with `EPERM` **and correct Unix permissions**. | Documented | -| Silent property rejection | `label.max_chars`, `blur_radius`, `notch_*` are **not** echoed by `--query`. A wrong value looks identical to a right one. | Documented | - -**Recommended implementation.** Add a repository self-check, runnable and CI-able: - -```bash -#!/usr/bin/env bash -# scripts/lint-sketchybar.sh — catches the four silent-failure classes. -set -u -cd "$(dirname "$0")/.." || exit 1 -P=sketchybar/.config/sketchybar/plugins -RC=sketchybar/.config/sketchybar/sketchybarrc -fail=0 - -# 1. every plugin executable -for f in "$P"/*.sh; do - [ -x "$f" ] || { echo "FAIL: $f is not executable (sketchybar will never run it)"; fail=1; } -done - -# 2. every self-hiding item declares updates=on -for f in "$P"/*.sh; do - grep -q 'set "$NAME" drawing=off\|set "$NAME" .*drawing=off' "$f" || continue - item=$(basename "$f" .sh) - awk -v it="$item" ' - $0 ~ "--add item "it" " {found=1} - found && /updates=on/ {ok=1} - found && /^sketchybar|^# ---/ && !/--add item/ {found=0} - END {exit ok?0:1}' "$RC" \ - || { echo "FAIL: item '$item' self-hides but lacks updates=on"; fail=1; } -done - -# 3. shellcheck if available -command -v shellcheck >/dev/null && shellcheck -S warning "$P"/*.sh || true - -exit $fail -``` - -Wire into `.github/workflows/` (the repo already has a `.github/` directory) and document in -`CLAUDE.md` as a pre-commit step. - -**Edge cases.** The awk item-block detection is heuristic. Accept false negatives; a linter that -catches most cases beats none. Do not make it block on ambiguity. - -**Acceptance criteria.** -1. `scripts/lint-sketchybar.sh` exits 0 on the current tree. -2. `chmod -x` any plugin → exits 1 naming it. -3. Remove `updates=on` from `mic` → exits 1 naming it. - ---- - -## 5. Medium Findings - -### ARCH-001 — No shared plugin library; six duplication sites - -| | | -|---|---| -| **Category** | Architecture | -| **Severity** | Medium | -| **Files** | all 17 plugins | -| **Effort** | 2 h | -| **Depends on** | Nothing (but touches every plugin — schedule to avoid conflicts) | - -**Measured duplication:** - -| Pattern | Count | Files | -|---|---:|---| -| `source .../colors.sh` | 15 / 17 | all but `clock.sh`, `front_app.sh` | -| `hide()` redefined | 3 | `calendar.sh`, `music.sh`, `mic.sh` | -| `cut -c1-N` + ellipsis truncation | 3 | `bluetooth.sh`, `calendar.sh`, `vpn.sh` | -| `${TMPDIR:-/tmp}/sketchybar-*` state | 3 | `github.sh`, `pomodoro.sh`, `weather.sh` | -| Verbatim `osascript` block | 2× in 1 file | `amphetamine.sh:17-22` ≡ `:34-39` | -| Helper build-on-demand block | 2 | `mic.sh`, `system.sh` | - -**Recommended implementation.** Add `sketchybar/.config/sketchybar/lib.sh`: - -```bash -#!/usr/bin/env bash -# Shared plugin helpers. Source AFTER colors.sh. -# Every function assumes $NAME is set by sketchybar. - -# Hide this item and exit. The single most repeated idiom in the plugins. -hide() { sketchybar --set "$NAME" drawing=off "${@}"; exit 0; } - -# Truncate to $2 chars with an ellipsis. Pure parameter expansion — no subprocess. -truncate_label() { - local s=$1 n=$2 - if [ "${#s}" -gt "$n" ]; then printf '%s…' "${s:0:$n}"; else printf '%s' "$s"; fi -} - -# Path for a plugin's cache/state file. -state_file() { printf '%s/sketchybar-%s' "${TMPDIR:-/tmp}" "$1"; } - -# Build a compiled helper on demand; echo its path, or return 1. -ensure_helper() { - local name=$1 dir="$HOME/.config/sketchybar/helpers" - local src="$dir/$name.swift" bin="$dir/$name" - if [ ! -x "$bin" ] || [ "$src" -nt "$bin" ]; then - [ -r "$src" ] && command -v swiftc >/dev/null 2>&1 || return 1 - swiftc -O -o "$bin.new" "$src" >/dev/null 2>&1 \ - && mv "$bin.new" "$bin" || { rm -f "$bin.new"; return 1; } - fi - printf '%s' "$bin" -} -``` - -Note `truncate_label` replaces a `printf | cut` **pipeline of two subprocesses** with pure -parameter expansion — applied 3× on every relevant plugin invocation. - -**Risks.** Touching all 17 plugins at once maximises merge conflict surface. Do this as its own -commit, after PERF-001/002/003 have landed. - -**Acceptance criteria.** `grep -c '^hide()' plugins/*.sh` → 0; `grep -lc 'cut -c1-' plugins/*.sh` -→ empty; every plugin still behaves identically (spot-check each item after reload). - ---- - -### ARCH-002 — `sketchybarrc` is a 951-line monolith - -| | | -|---|---| -| **Category** | Architecture | -| **Severity** | Medium | -| **Files** | `sketchybarrc` | -| **Effort** | 2 h | -| **Depends on** | ARCH-001 | - -**Description.** 951 lines in one file, overwhelmingly comment. The comments are the repository's -best asset, so the fix is **not** to delete them — it is to give them a smaller scope to describe. - -**Recommended structure:** -``` -sketchybar/.config/sketchybar/ -├── sketchybarrc # ~120 lines: bar setup, defaults, sources the rest, --update -├── lib.sh # shared plugin helpers (ARCH-001) -├── colors.sh # palette (unchanged) -├── items/ -│ ├── workspaces.sh # island.spaces -│ ├── app.sh # island.app -│ ├── media.sh # island.media + popup -│ ├── system.sh # island.system -│ ├── status.sh # island.status -│ ├── network.sh # island.network -│ ├── hardware.sh # island.hardware -│ └── time.sh # island.time -├── plugins/ # unchanged -└── helpers/ # unchanged -``` - -`sketchybarrc` becomes: -```bash -source "$CONFIG_DIR/colors.sh" -for f in "$CONFIG_DIR"/items/*.sh; do source "$f"; done -sketchybar --update -``` - -**Critical ordering constraint.** SketchyBar lays items out in **add order** — first-added is -leftmost on the left side and **rightmost** on the right side. Sourcing `items/*.sh` alphabetically -would silently reorder the entire bar. Source them **explicitly in the current order**, and put a -comment saying why the glob is not used: -```bash -# EXPLICIT ORDER, NOT A GLOB. Add order IS the layout — see the note in items/time.sh. -for f in workspaces app media time hardware network status system; do - source "$CONFIG_DIR/items/$f.sh" -done -``` - -**Risks.** High conflict surface; the bracket definitions at the bottom reference items defined -throughout. Brackets must stay in a single file sourced last, or each `items/*.sh` must define its -own bracket (preferred — it colocates the island with its members). - -**Acceptance criteria.** Byte-identical bar geometry before/after: -```bash -for b in island.spaces island.app island.media island.system island.status \ - island.network island.hardware island.time; do - sketchybar --query "$b" | jq -c '.bounding_rects."display-1"' -done # diff before vs after must be empty -``` - ---- - -### SEC-001 — Unauthenticated IP-geolocating request every 15 minutes - -| | | -|---|---| -| **Category** | Security / Privacy | -| **Severity** | Medium | -| **Files** | `plugins/weather.sh:6,9` | -| **Effort** | 15 min | -| **Depends on** | Nothing | - -**Description.** `LOCATION=""` produces `https://wttr.in/?format=%C|%t`. With an empty location, -wttr.in **geolocates by source IP**. The bar therefore discloses the host's public IP to a -third-party service every 900 s, and receives location-correlated data back. - -**Impact.** Low for a personal machine on a home connection; meaningful if this repository is -open-sourced and copied, because the behaviour is not obvious from reading the config — and -`sketchybarrc:712` tells the reader a privacy note exists that does not. - -**Recommended implementation.** Document it and make it opt-out: -```bash -#!/usr/bin/env bash -# Current conditions from wttr.in. -# -# PRIVACY: with LOCATION empty, wttr.in geolocates by SOURCE IP — every poll -# discloses this host's public IP to a third party and returns location-correlated -# data. Set LOCATION to a city name to send an explicit location instead (still a -# third-party request, but no IP-based inference), or set it to "off" to disable -# the item entirely. -LOCATION="${SKETCHYBAR_WEATHER_LOCATION:-}" - -[ "$LOCATION" = "off" ] && { sketchybar --set "$NAME" drawing=off; exit 0; } -``` -Also add `--fail` to the curl invocation so HTTP errors do not populate the cache with an error page: -```bash -reading="$(curl -sf --max-time 5 "https://wttr.in/${LOCATION}?format=%C|%t" 2>/dev/null)" -``` - -**Acceptance criteria.** `weather.sh` contains the word "privacy"; `SKETCHYBAR_WEATHER_LOCATION=off` -hides the item; a 500 from wttr.in does not overwrite the cache. - ---- - -### PORT-001 — Hardcoded `/opt/homebrew` breaks on Intel Macs - -| | | -|---|---| -| **Category** | Portability | -| **Severity** | Medium | -| **Files** | `sketchybarrc:61`, `raycast/.config/raycast/scripts/reload-sketchybar.sh:23` | -| **Effort** | 15 min | -| **Depends on** | Nothing | - -**Description.** Both files hardcode the Apple Silicon Homebrew prefix. Intel Macs use -`/usr/local`. On Intel, `sketchybarrc` would fail to find `aerospace`, `macmon`, `gh` and -`icalBuddy`, and every dependent plugin would silently degrade. - -**Recommended implementation.** Prepend both prefixes — harmless when one does not exist: -```bash -# Both Homebrew prefixes: /opt/homebrew (Apple Silicon), /usr/local (Intel). -# Prepending a nonexistent directory is harmless, and this avoids a `brew --prefix` -# subprocess on every bar start. -export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:$PATH" -``` - -**Acceptance criteria.** `grep -c '/opt/homebrew' --include='*.sh' -r .` shows every hit paired -with a `/usr/local` fallback. - ---- - -### SB-002 — No capability detection for eight third-party binaries - -| | | -|---|---| -| **Category** | Reliability / Portability | -| **Severity** | Medium | -| **Files** | `plugins/*.sh`, `Brewfile` | -| **Effort** | 45 min | -| **Depends on** | ARCH-001 | - -**Description.** Plugins depend on `aerospace`, `macmon`, `gh`, `icalBuddy`, `curl`, `osascript`, -`system_profiler`, `pmset`. Only `calendar.sh` guards (`command -v icalBuddy || hide`). The rest -fail into empty output, and most then hide — indistinguishable from "nothing to report". - -**Impact.** On a fresh machine where `macmon` is absent, the system island vanishes with no -indication that a dependency is missing rather than idle. - -**Recommended implementation.** Standardise via `lib.sh`: -```bash -# Require a command or hide with a one-time warning to stderr (visible in sketchybar's log). -require() { - command -v "$1" >/dev/null 2>&1 && return 0 - printf 'sketchybar/%s: missing dependency: %s\n' "${NAME:-?}" "$1" >&2 - hide -} -``` -Apply as `require macmon` / `require gh` / `require aerospace` at the top of each plugin. - -**Acceptance criteria.** Temporarily rename `macmon`; the island hides **and** the reason appears -on stderr. - ---- - -### SH-001 — Unnecessary subprocesses in hot paths - -| | | -|---|---| -| **Category** | Shell / Performance | -| **Severity** | Medium | -| **Files** | `battery.sh:8-9`, `calendar.sh:28-31`, `wifi.sh:7-11`, `aerospace.sh:19` | -| **Effort** | 45 min | -| **Depends on** | ARCH-001 | - -**Findings:** - -**`battery.sh:8-9`** — 5 subprocesses to parse one `pmset` output: -```bash -pct="$(printf '%s' "$batt" | grep -Eo '[0-9]+%' | head -1 | tr -d '%')" -charging="$(printf '%s' "$batt" | grep -c "AC Power")" -``` -Replace with parameter expansion: -```bash -pct="${batt#* }"; pct="${pct%%\%*}"; pct="${pct##* }" -case "$batt" in *"AC Power"*) charging=1 ;; *) charging=0 ;; esac -``` -Also note a semantic bug: `charging` is true whenever on AC, but a **full battery on AC is not -charging**. The green colour therefore claims "charging" while merely plugged in. Use -`pmset`'s own `; charging` / `; charged` suffix. - -**`calendar.sh:28-31`** — 4 subprocesses (`printf | tr | sed | sed`) for title cleanup. Collapse -to a single `sed` with multiple `-e`, or better, have `icalBuddy` emit a cleaner format. - -**`wifi.sh:7-11`** — `networksetup -listallhardwareports` (**40 ms measured**) plus `awk` plus -`ifconfig` plus `grep`, on every invocation, to discover a device name **that never changes**. -Cache it: -```bash -DEV_CACHE="$(state_file wifi-dev)" -if [ -s "$DEV_CACHE" ]; then read -r dev < "$DEV_CACHE"; else - dev="$(networksetup -listallhardwareports 2>/dev/null \ - | awk '/Hardware Port: Wi-Fi/{getline; print $2; exit}')" - dev="${dev:-en0}"; printf '%s' "$dev" > "$DEV_CACHE" -fi -``` - -**Acceptance criteria.** `battery.sh` and `wifi.sh` each spawn ≤ 2 subprocesses; all items render -identically. - ---- - -### SB-003 — `music.sh` polls Apple Music via AppleScript every 10 s - -| | | -|---|---| -| **Category** | Performance | -| **Severity** | Medium | -| **Files** | `plugins/music.sh`, `sketchybarrc` (music item) | -| **Effort** | 30 min | -| **Depends on** | Nothing | - -**Description.** ~120 ms per invocation at `update_freq=10` → **720 ms/min** (14% of budget), -paid whether or not Music is running. The `pgrep -x Music` guard short-circuits when Music is -closed, which is the common case — but when it *is* open, this is continuous AppleScript IPC. - -**Critical note:** the `pgrep` guard is **load-bearing, not defensive**. Verified during this -audit: `osascript -e 'tell application "Music" ...'` **launches Music** when it is not running. -Removing the guard would cause the status bar to start a media player. Do not remove it. - -**Recommended implementation.** SketchyBar exposes a `media_change` event (`MEDIA_CHANGED` in -`src/event.h`) driven by `MPNowPlayingInfoCenter` — which covers **any** player, not just Apple -Music, and fires on change rather than on a timer: -``` ---add item music left ---subscribe music media_change mouse.clicked ---set music updates=on update_freq=0 ... -``` -`$INFO` carries the now-playing payload as JSON. **[INFERRED]** — the event exists in the binary; -the payload shape was not verified during this audit. Confirm with: -```bash -sketchybar --add item probe.m left --subscribe probe.m media_change \ - --set probe.m script='echo "$INFO" >> /tmp/media.log' -``` - -**Acceptance criteria.** `music` shows `update_freq=0`; starting playback updates the pill within -1 s; the `pgrep` guard is retained or provably unnecessary. - ---- - -## 6. Low Findings - -### LOW-001 — Four unused colour constants -`colors.sh` exports `BASE`, `MANTLE`, `SURFACE0`, `SURFACE1` — zero references in `sketchybarrc` -or any plugin. **Keep them.** `colors.sh` is a *palette*, and a complete Catppuccin Frappé palette -is more useful than a minimal one. Add a one-line header saying so, to stop a future reader -"tidying" them away. **Effort: 2 min.** - -### LOW-002 — `clock.sh` polls on a 15 s cycle unaligned to the minute -`update_freq=15` means the displayed minute can be up to 15 s stale, and 3 of every 4 invocations -are wasted. Aligning to the minute boundary is possible but adds complexity for a cosmetic gain. -**Recommendation: accept, and document the trade-off.** **Effort: 5 min (comment only).** - -### LOW-003 — `pomodoro.sh` notification uses unescaped interpolation -```bash -notify() { osascript -e "display notification \"$2\" with title \"$1\""; } -``` -Both arguments are string literals at every call site, so there is no injection path today. It is -a fragile pattern that would break on an apostrophe if the messages ever became dynamic. Use -`osascript -e '...' -- "$1" "$2"` with `on run argv`. **Effort: 10 min.** - -### LOW-004 — `pomodoro.sh` state file has a benign read/write race -The state file is read at the top and written by `start()`/`idle()`. Two invocations overlapping -(a click landing during a 1 s tick) could interleave. Consequence is at worst one wrong frame, -self-correcting on the next tick. **Recommendation: accept; document.** **Effort: 5 min.** - -### LOW-005 — `github.sh` "50+" is a page-size artefact -`gh api notifications --jq length` returns at most one page (50). The `>= 50` branch therefore -displays "50+" for exactly 50 as well as for 200. Correct but coincidental. Add `--paginate` or -document the heuristic. **Effort: 10 min.** - -### LOW-006 — No `set -u` in any plugin -No plugin sets `-u`. An unset `$NAME` (e.g. when run by hand for debugging) silently produces -`sketchybar --set "" ...`, which errors unhelpfully. Adding `set -u` would make manual invocation -fail fast. **Effort: 15 min across all plugins.** - ---- - -## 7. Low-Hanging Fruit - -Ordered by value ÷ effort. All are independent and none exceeds 30 minutes. - -| # | Action | Effort | Value | -|---|---|---:|---| -| 1 | `git add` + commit everything (**GIT-001/002**) | 20 min | Eliminates total-work-loss risk | -| 2 | `mic` `update_freq` 2 → 5 (**PERF-001 tier 1**) | 2 min | **−1,134 ms/min (−22% of total budget)** | -| 3 | `system` `update_freq` 30 → 60 (**PERF-002 interim**) | 2 min | **−930 ms/min (−18%)** | -| 4 | Fix the three false comments (**DOC-001**) | 10 min | Removes actively misleading docs | -| 5 | Enable the Raycast floating rule | 5 min | Ships a feature blocked by a stale comment | -| 6 | `/usr/local` PATH fallback (**PORT-001**) | 15 min | Unblocks Intel Macs | -| 7 | `curl -sf` in `weather.sh` | 2 min | Stops caching HTTP error pages | -| 8 | Privacy note in `weather.sh` (**SEC-001**) | 15 min | Makes a network behaviour discoverable | -| 9 | Correct the `mic.swift` "single-digit ms" claim | 5 min | Removes the false premise behind the worst perf bug | -| 10 | Drop `--monitor all` from `aerospace.sh` | 2 min | Dead argument on a single screen | - -**Items 2 and 3 alone cut the bar's CPU consumption by 40% for four minutes of work.** - ---- - -## 8. Suggested Refactors - -**R1 — Extract `lib.sh`** (ARCH-001). Prerequisite for R2 and SB-002. - -**R2 — Split `sketchybarrc` into `items/`** (ARCH-002). Depends on R1. Preserve add order explicitly. - -**R3 — Convert polled items to event-driven.** `mic` (PERF-001 tier 2) and `music` (SB-003). -Target: idle CPU below 1,500 ms/min, a 71% reduction. `volume` and `wifi` are the existing -in-repo precedents. - -**R4 — Add a linter and CI** (SB-001). The repo already has `.github/`; nothing uses it. - ---- - -## 9. Recommended Directory Structure - -``` -dotfiles/ -├── .github/workflows/lint.yml # NEW — runs scripts/lint-sketchybar.sh + shellcheck -├── scripts/ -│ └── lint-sketchybar.sh # NEW — the four silent-failure checks -├── docs/ -│ └── audit-2026-08-02.md # this file -├── sketchybar/.config/sketchybar/ -│ ├── sketchybarrc # ~120 lines -│ ├── colors.sh -│ ├── lib.sh # NEW -│ ├── items/ # NEW — one file per island -│ ├── plugins/ -│ └── helpers/ -└── (other stow packages unchanged) -``` - -`scripts/` sits outside the stow packages deliberately — it is repository tooling, not dotfiles, -and must not be symlinked into `$HOME`. - ---- - -## 10. Implementation Roadmap - -Ordered so no phase depends on a later one, and so file-level conflicts are minimised. - -### Phase 1 — Critical (≈ 1 h) -| ID | Task | Files | -|---|---|---| -| GIT-001 | Track `thermal.swift`; harden both helper guards | `install`, git index | -| GIT-002 | Commit the working tree in coherent units | all | -| DOC-001 | Fix three false comments | `system.sh`, `sketchybarrc`, `aerospace.toml` | - -*No file overlap with later phases except `sketchybarrc` comments — land first.* - -### Phase 2 — High-value, low-risk (≈ 2 h) -| ID | Task | Files | -|---|---|---| -| PERF-001 t1 | `mic` interval 2 → 5; correct the comment | `sketchybarrc`, `mic.swift` | -| PERF-002 int | `system` interval 30 → 60 | `sketchybarrc` | -| PERF-003 | Single-query workspace repaint | `aerospace.sh`, `sketchybarrc` | -| PORT-001 | Homebrew prefix fallback | `sketchybarrc`, `reload-sketchybar.sh` | -| SEC-001 | Weather privacy note + `curl -sf` | `weather.sh` | - -*Delivers the entire measured performance win. `sketchybarrc` is touched by three tasks — do them -in one commit.* - -### Phase 3 — Refactoring (≈ 6 h) -| ID | Task | Depends on | -|---|---|---| -| ARCH-001 | Extract `lib.sh`, migrate all 17 plugins | Phase 2 | -| SH-001 | Remove subprocesses; fix the AC-vs-charging bug | ARCH-001 | -| SB-002 | `require()` dependency guards | ARCH-001 | -| ARCH-002 | Split `sketchybarrc` into `items/` | ARCH-001 | -| SB-001 | Linter + CI | ARCH-001 | - -*High conflict surface. One task per commit; verify bar geometry unchanged after each.* - -### Phase 4 — Nice to have (≈ 5 h) -| ID | Task | -|---|---| -| PERF-001 t2 | Event-driven `mic` daemon | -| SB-003 | `media_change`-driven `music` | -| LOW-001…006 | Documentation and hardening | - ---- - -## 11. Final Verdict - -### Would I ship this? - -**As a personal configuration — yes, and I would run it daily.** It is more carefully reasoned -than most production infrastructure I have reviewed. The habit of recording *measurements* rather -than *intentions* in comments is genuinely rare and repeatedly paid off during this audit: several -findings were reachable only because a previous measurement was written down. - -**As an open-source project — not yet.** Three blockers: - -1. **It cannot be cloned successfully.** A required file is untracked and its absence is silent. -2. **It cannot run on a second machine.** Intel Macs fail on PATH; every plugin assumes Apple - Silicon Homebrew, and eight binary dependencies are unguarded. -3. **It has no automated verification of any kind.** For a configuration whose characteristic - failure mode is *silence*, the absence of a linter is the highest structural risk here. - -### Biggest risks - -**The documentation is load-bearing and drifting.** Three false comments were found in a -configuration where comments carry the measurements. This is the failure mode that will hurt most, -because the comments are precisely what makes the repository valuable. - -**Performance is unmonitored.** 8.6% of a core is being spent with no one watching. The `mic` -finding shows how it happened: a plausible-but-wrong comment ("single-digit ms") justified an -aggressive interval, and nothing ever re-checked it. - -### What fails after six months? - -- **`macmon`, `icalBuddy`, `gh`** — upstream changes break parsing; failures are silent. -- **Private APIs.** `IOHIDEventSystemClient` (thermal), `SLSSetWindowBackgroundBlurRadius` (pills), - `DisplayServices` (brightness) are all undocumented and all plausible macOS-update casualties. - Failure modes are contained, which is good design — but there are three of them. -- **The `~/Library/DoNotDisturb` layout** — already at `version 8`; Apple has changed it before. -- **TCC.** Any tightening of `~/Library` access silently degrades more plugins, as it already did - for the abandoned Focus indicator. - -### What should be rewritten, removed, simplified? - -**Rewritten:** `aerospace.sh` (per-item global queries → single-query repaint); `mic` (poll → -CoreAudio listener); `sketchybarrc` (monolith → `items/`). - -**Removed:** nothing functional. Four unused colour constants should be *kept* — a complete palette -is correct. Resist the urge to prune it. - -**Simplified:** the `hide()`/truncate/state-file/helper-build idioms into `lib.sh` — six duplication -sites collapse into four functions. - -### One thing to do today - -Commit the work, then change two numbers: -``` -mic update_freq 2 → 5 -system update_freq 30 → 60 -``` -That is four minutes for a **40% reduction in CPU consumption** and the elimination of total -work-loss risk. Everything else in this report can wait for a weekend. diff --git a/scripts/lint-aerospace.sh b/scripts/lint-aerospace.sh new file mode 100755 index 0000000..7b43673 --- /dev/null +++ b/scripts/lint-aerospace.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Catches the AeroSpace failure classes that are INVISIBLE when they happen — +# the sibling of scripts/lint-sketchybar.sh, same philosophy. +# +# AeroSpace's own validator (`reload-config --dry-run --warnings-as-errors`) +# already covers syntax and unknown commands, so this script does NOT re-check +# those. What it adds is the two CROSS-FILE couplings the validator cannot see, +# because each half is individually valid — they are only wrong together: +# +# * gaps.outer.top must track SketchyBar's bar height. Get it wrong and windows +# tuck under the bar or leave a band of wallpaper; nothing errors. +# * the border colour AeroSpace settles on after its focus pulse must match the +# resting colour in bordersrc. Get it wrong and every focus change "settles" +# on the old colour; nothing errors. +# +# Plus the ~/.aerospace.toml ambiguity trap, and — when the AeroSpace app is +# actually running — the built-in validator as a bonus. +# +# Run by hand or from CI. Exits non-zero on any failure. The static checks need +# no binary and no running app, so they are CI-safe; the dry-run self-skips. + +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +TOML="aerospace/.config/aerospace/aerospace.toml" +RC="sketchybar/.config/sketchybar/sketchybarrc" +BORDERS="borders/.config/borders/bordersrc" +CLEARANCE=5 # gaps.outer.top - bar height, by design +fail=0 + +note() { printf ' %s\n' "$*"; } +bad() { printf 'FAIL %s\n' "$*"; fail=1; } + +# --------------------------------------------------------------- +# 0. The files must exist where we expect them. +for f in "$TOML" "$RC" "$BORDERS"; do + [ -f "$f" ] || { bad "missing file: $f"; } +done +[ "$fail" -eq 0 ] || { printf '\nFAIL — repo layout unexpected\n'; exit 1; } + +# --------------------------------------------------------------- +# 1. Top gap must equal the SketchyBar bar height + clearance. +# +# aerospace.toml `gaps.outer.top` and sketchybarrc `--bar height` are coupled; +# the comments in both files spell out the +5. The height= match is anchored so +# it does not pick up `background.height=`. +printf '\n== bar height <-> top gap coupling ==\n' +height="$(grep -oE '^[[:space:]]+height=[0-9]+' "$RC" | grep -oE '[0-9]+' | head -1)" +top="$(grep -E '^gaps\.outer\.top' "$TOML" | grep -oE '[0-9]+' | head -1)" +if [ -z "$height" ] || [ -z "$top" ]; then + bad "could not read height ('$height') or top gap ('$top')" +elif [ "$top" -eq "$((height + CLEARANCE))" ]; then + note "ok top=$top == height=$height + $CLEARANCE" +else + bad "top gap $top != bar height $height + $CLEARANCE (=$((height + CLEARANCE))) — windows will tuck under the bar or leave a band" +fi + +# --------------------------------------------------------------- +# 2. Border settle colour must match bordersrc's resting active colour. +# +# on-focus-changed pulses to lavender then back to a mauve; that second (settle) +# colour is the one that must equal bordersrc active_color. Take the LAST +# active_color on the pulse line as the settle value. +printf '\n== border settle colour <-> bordersrc ==\n' +settle="$(grep -E 'active_color=0x' "$TOML" | grep -oE 'active_color=0x[0-9a-fA-F]+' | tail -1 | cut -d= -f2)" +resting="$(grep -oE 'active_color=0x[0-9a-fA-F]+' "$BORDERS" | head -1 | cut -d= -f2)" +if [ -z "$settle" ] || [ -z "$resting" ]; then + bad "could not read settle ('$settle') or bordersrc active ('$resting')" +elif [ "$settle" = "$resting" ]; then + note "ok settle=$settle == bordersrc active=$resting" +else + bad "on-focus-changed settles on $settle but bordersrc rests at $resting — every focus change will settle on the wrong colour" +fi + +# --------------------------------------------------------------- +# 3. No stray ~/.aerospace.toml. +# +# AeroSpace reads EITHER ~/.aerospace.toml OR the XDG path; having both is a +# hard error and the app refuses to start. The repo config lives only at the +# XDG path, so the home-dir file must not exist. +printf '\n== no ambiguous ~/.aerospace.toml ==\n' +if [ -e "$HOME/.aerospace.toml" ]; then + bad "$HOME/.aerospace.toml exists — AeroSpace will error on the ambiguity with the XDG config" +else + note "ok none present" +fi + +# --------------------------------------------------------------- +# 4. AeroSpace's own validator, when the app is reachable. +# +# reload-config is a client->server command: it validates the INSTALLED (stowed) +# config and needs the app running. Skip cleanly when the binary is absent (CI) +# or the server is down, so this stays a bonus rather than a false failure. +printf '\n== aerospace validator ==\n' +if ! command -v aerospace >/dev/null 2>&1; then + note "skip aerospace not installed" +elif ! aerospace list-workspaces --all >/dev/null 2>&1; then + note "skip AeroSpace app not running (validator needs the server)" +elif aerospace reload-config --dry-run --warnings-as-errors >/dev/null 2>&1; then + note "ok reload-config --dry-run --warnings-as-errors" +else + bad "aerospace reload-config --dry-run --warnings-as-errors reported problems" +fi + +printf '\n' +if [ "$fail" -eq 0 ]; then printf 'PASS — no silent-failure patterns found\n' +else printf 'FAIL — see above\n'; fi +exit "$fail" diff --git a/sketchybar/.config/sketchybar/colors.sh b/sketchybar/.config/sketchybar/colors.sh index 0715ef6..29bd20e 100755 --- a/sketchybar/.config/sketchybar/colors.sh +++ b/sketchybar/.config/sketchybar/colors.sh @@ -23,28 +23,5 @@ export LAVENDER=0xffbabbf1 export TRANSPARENT=0x00000000 -# Island backgrounds. The bar itself is fully transparent (color=0x0 in -# sketchybarrc) — these pills are the only thing drawn, so their alpha byte is -# the single knob for how see-through the whole bar looks. -# 0xe6 90% 0xcc 80% 0xb3 70% (current) 0x99 60% 0x80 50% -# Below about 60% the Catppuccin text starts losing contrast on a busy wallpaper — -# though the pills are frosted now (see ISLAND_STYLE in sketchybarrc), and blur -# buys back some of that headroom by killing the detail behind the text. -# -# BLUR IS PER-ITEM, NOT BAR-ONLY. An older version of this note said blur was a -# bar property with "no background.blur_radius", and concluded that using it -# would frost the whole bar rectangle including the gaps — a band across the -# screen top, which is exactly what the islands exist to avoid. Half right: -# - true: there is no background.blur_radius, so blur is not something a pill -# inherits from its background the way it inherits colour. -# - false: it is not bar-only. blur_radius is also an ITEM property -# (PROPERTY_BLUR_RADIUS, src/bar_item.c), applied to that item's own -# window — and a bracket IS an item whose window is the island rect. -# So the pills can be frosted individually while the bar stays transparent, which -# is what this config does. A full-width frosted band was tried and rejected. export ISLAND=0xb3232634 -# Kept fully opaque on purpose: as the fill gets more transparent the border is -# what keeps the pill's edge crisp and its shape readable against the desktop. -# It matters more with blur, not less — a frosted fill has softer edges than a -# flat one, so the border is what stops the pill dissolving into the wallpaper. export ISLAND_BORDER=0xff414559 diff --git a/sketchybar/.config/sketchybar/helpers/mic.swift b/sketchybar/.config/sketchybar/helpers/mic.swift index edc2161..e3b6620 100644 --- a/sketchybar/.config/sketchybar/helpers/mic.swift +++ b/sketchybar/.config/sketchybar/helpers/mic.swift @@ -2,38 +2,6 @@ // // Prints "on" or "off". Used by plugins/mic.sh. // -// WHY THIS AND NOT ioreg: every shell recipe for this reads -// AppleHDAEngineInput or AppleUSBAudioEngine out of the IORegistry. Both were -// measured returning nothing at all on this machine (Apple Silicon, macOS 26) — -// the properties those recipes match on are gone. -// -// WHY IT NEEDS NO PERMISSION: this only enumerates devices and reads a property. -// It never opens an input stream, so it never touches TCC and never raises a -// microphone prompt. kAudioDevicePropertyDeviceIsRunningSomewhere is true when -// ANY process on the system is running that device — which is exactly the -// question, and is why this reports other apps' recording, not our own. -// -// WHY IT IS COMPILED: `/usr/bin/swift` interpreting even a trivial script -// measured 1.25s, which is not pollable. mic.sh builds it on demand; the install -// script builds it up front. -// -// IT IS NOT CHEAP, AND THIS COMMENT USED TO CLAIM IT WAS. The previous wording -// said "compiled it runs in single-digit ms", which is wrong by a factor of 8 — -// measured 60/63/65ms (min/median/max over 10 consecutive runs). That false -// premise is what justified polling it every 2 seconds, which cost 1890ms of CPU -// per MINUTE and made this the single most expensive thing in the whole config. -// The interval is now 5s. Re-measure before lowering it again: -// python3 -c "import subprocess,time -// ts=[(lambda s: (subprocess.run(['$HOME/.config/sketchybar/helpers/mic'], -// capture_output=True), (time.time()-s)*1000)[1])(time.time()) for _ in range(10)] -// print(sorted(ts)[5])" -// -// The cost is inherent to the approach, not to Swift: this enumerates EVERY -// CoreAudio device, then queries two properties per device, so it is O(devices) -// IPC round-trips into coreaudiod on every single tick. The way to make it free -// is not micro-optimisation but AudioObjectAddPropertyListener on -// kAudioDevicePropertyDeviceIsRunningSomewhere, turning this into a resident -// listener that pushes a sketchybar event instead of being polled. import CoreAudio import Foundation @@ -52,9 +20,6 @@ func devices() -> [AudioObjectID] { return ids } -// A device is an input if it has at least one channel on the input scope. -// Output-only devices report a zero-length stream configuration there, and every -// speaker on the system would otherwise count as a microphone. func hasInput(_ id: AudioObjectID) -> Bool { var addr = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyStreamConfiguration, diff --git a/sketchybar/.config/sketchybar/helpers/thermal.swift b/sketchybar/.config/sketchybar/helpers/thermal.swift index 423e26f..e546132 100644 --- a/sketchybar/.config/sketchybar/helpers/thermal.swift +++ b/sketchybar/.config/sketchybar/helpers/thermal.swift @@ -2,40 +2,6 @@ // // Prints one number to one decimal, or nothing at all with a non-zero exit when // no usable sensor exists. Used by plugins/system.sh. -// -// WHY THIS AND NOT macmon's temp.cpu_temp_avg, which is what system.sh used to -// read: that value is not trustworthy on this machine. Measured inside a SINGLE -// macmon invocation, with CPU load flat at 1.5-3.7% and cpu_power at 0.06W: -// t+0s cpu=37.74 gpu=35.33 -// t+2s cpu=31.79 gpu=35.27 -// t+4s cpu=20.19 gpu=35.27 <- 17 degrees in two seconds, at idle -// A die cannot cool 17 degrees in two seconds and reheat. Across repeated trials -// the value is BIMODAL — it lands on either ~31.9 or ~38.1 and never in between, -// which is the signature of a mean taken over a VARYING SET of sensors rather -// than of anything thermal. macmon's gpu_temp_avg, from the same sample, is -// rock steady, and equals the tdie mean this file computes. -// -// WHAT THIS MACHINE ACTUALLY EXPOSES (39 services, 20 distinct names): -// PMU tdie1..tdie10 avg 35.25 max 36.11 the SoC die sensors -// PMU tdev1..tdev8 28.8 - 32.1 device/package -// PMU tcal 51.85 CALIBRATION, not a temperature -// NAND CH0 temp 29.0 SSD -// There is no sensor named "CPU". Apple Silicon puts CPU, GPU and ANE on one -// die, so a "CPU temperature" is always derived from die sensors — which is why -// this reports the die mean and calls it that. -// -// WHY IT NEEDS NO PERMISSION: this reads the HID temperature services, which are -// world-readable. It is not powermetrics and needs no sudo. -// -// WHY IT IS COMPILED: same reason as mic.swift — `swift` interpreting even a -// trivial script measured 1.25s there, which is not pollable. system.sh builds -// it on demand; the install script builds it up front. -// -// PRIVATE API. IOHIDEventSystemClient* is not public SPI. It is the standard -// no-sudo route to these sensors and is what every Apple Silicon temperature -// tool uses, but it is a plausible casualty of a macOS update. The failure mode -// is contained: this exits non-zero and system.sh drops the temperature from the -// label rather than showing a wrong one. import Foundation import IOKit @@ -47,8 +13,6 @@ private typealias EventFn = @convention(c) (AnyObject?, Int64, Int32, Int64) -> private typealias FloatFn = @convention(c) (AnyObject?, Int64) -> Double private typealias PropFn = @convention(c) (AnyObject?, CFString) -> AnyObject? -// kIOHIDEventTypeTemperature. The value is fetched with the type shifted into -// the high half, which is how IOHIDEventGetFloatValue addresses event fields. private let kTemperature: Int64 = 15 private func sensors() -> [(name: String, value: Double)] { @@ -68,7 +32,6 @@ private func sensors() -> [(name: String, value: Double)] { else { return [] } let client = create(kCFAllocatorDefault) - // Usage page 0xff00 / usage 5 is the temperature sensor class. setMatching(client, ["PrimaryUsagePage": 0xff00, "PrimaryUsage": 5] as CFDictionary) guard let services = copyServices(client) as? [AnyObject] else { return [] } @@ -82,13 +45,6 @@ private func sensors() -> [(name: String, value: Double)] { let all = sensors() -// PREFERENCE ORDER, so this is not wired to one Apple Silicon generation. This -// machine only has the PMU names; other Macs expose per-cluster MTR sensors and -// would match the later rules instead. -// -// "PMU tcal" is excluded DELIBERATELY and is the reason this matches "PMU tdie" -// rather than the shorter "PMU t": tcal reads 51.85 here, is a calibration -// reference rather than a temperature, and would silently drag the mean up. let rules: [(String, (String) -> Bool)] = [ ("PMU tdie", { $0.hasPrefix("PMU tdie") }), ("cluster MTR", { $0.contains("ACC MTR Temp") }), @@ -103,7 +59,4 @@ for (_, matches) in rules { } } -// No usable sensor. Print NOTHING and fail — the caller has to be able to tell -// "no reading" apart from "zero degrees", which is the same rule volume.sh and -// brightness.sh follow. exit(1) diff --git a/sketchybar/.config/sketchybar/lib.sh b/sketchybar/.config/sketchybar/lib.sh index 511e3ec..c1361ec 100644 --- a/sketchybar/.config/sketchybar/lib.sh +++ b/sketchybar/.config/sketchybar/lib.sh @@ -1,53 +1,15 @@ #!/usr/bin/env bash # Shared plugin helpers. -# -# Source AFTER colors.sh: -# source "$HOME/.config/sketchybar/colors.sh" -# source "$HOME/.config/sketchybar/lib.sh" -# -# Every function assumes $NAME is set — sketchybar sets it when it runs a plugin. -# Running a plugin by hand without NAME produces `sketchybar --set "" ...`, which -# errors unhelpfully; that is the one case worth knowing about when debugging. -# -# WHY THIS EXISTS: before it, `hide()` was defined identically in three plugins, -# the truncate idiom was copy-pasted into three more, the helper build-on-demand -# block existed twice, and one plugin had a six-line osascript block duplicated -# verbatim inside itself. None of that is hard to write; all of it is easy to fix -# in one place and forget in the other five. -# --------------------------------------------------------------- -# Hide this item and exit. -# -# The single most repeated idiom in the plugins, and the one with a trap: an item -# that hides itself MUST also be declared `updates=on` in sketchybarrc. Under the -# config-wide `updates=when_shown` default a hidden item stops being updated -# entirely — bar_item_update() gates timed AND event runs behind -# `updates_only_when_shown ? is_shown : true`, and bar_draw() clears the bar -# association of anything it does not draw. The item then works right after a -# reload, hides itself, and never runs again, silently. Eight items were found in -# that state. scripts/lint-sketchybar.sh checks for it. -# -# It closes any popup too. Two of the three plugins that had their own hide() -# did that, and the third has no popup — and `popup.drawing=off` on an item -# without one is accepted silently (verified), so doing it unconditionally is -# safe and removes a footgun: a bare `hide` that left a popup open on screen -# would be a subtle, rare bug to chase. -# -# Extra arguments are passed through for anything else an item needs to reset. +# The "$@" pass-through is deliberate — it lets a plugin reset one more property +# while hiding. No caller needs it today, which is exactly what SC2120 warns +# about, so the check is a false positive on an API kept open on purpose. +# shellcheck disable=SC2120 hide() { sketchybar --set "$NAME" drawing=off popup.drawing=off "$@" exit 0 } -# --------------------------------------------------------------- -# Truncate to at most $2 characters, appending an ellipsis if it was cut. -# -# Pure parameter expansion. The idiom this replaces — -# printf '%s' "$s" | cut -c1-"$n" -# — is a pipeline of two subprocesses, run on every invocation of every plugin -# that displays a name. `cut -c` also counts BYTES in some implementations while -# ${#s} and ${s:0:n} count characters, so the old form truncated UTF-8 names -# mid-codepoint; this does not. truncate_label() { local s=$1 n=$2 if [ "${#s}" -gt "$n" ]; then @@ -57,23 +19,10 @@ truncate_label() { fi } -# --------------------------------------------------------------- -# Path for a plugin's cache or state file, e.g. state_file weather. -# -# $TMPDIR rather than /tmp: it is per-user and cleaned by macOS, so nothing -# accumulates and no other user can read or pre-create these paths. state_file() { printf '%s/sketchybar-%s' "${TMPDIR:-/tmp}" "$1" } -# --------------------------------------------------------------- -# Ensure a compiled Swift helper exists and is current; echo its path. -# Returns non-zero if it cannot be built, so callers can degrade. -# -# bin="$(ensure_helper thermal)" || bin="" -# -# Rebuilds when the binary is missing OR older than the source, so a `git pull` -# that changes a helper does not require re-running ./install. ensure_helper() { local name=$1 local dir="$HOME/.config/sketchybar/helpers" @@ -92,13 +41,6 @@ ensure_helper() { printf '%s' "$bin" } -# --------------------------------------------------------------- -# Require an external command, or hide the item and say why. -# -# Most plugins here depend on something Homebrew installed — aerospace, macmon, -# gh, icalBuddy. Without this they fail into empty output and then hide, which is -# indistinguishable from "nothing to report". The stderr line is what makes a -# missing dependency diagnosable rather than merely quiet. require() { command -v "$1" >/dev/null 2>&1 && return 0 printf 'sketchybar/%s: missing dependency: %s\n' "${NAME:-?}" "$1" >&2 diff --git a/sketchybar/.config/sketchybar/plugins/amphetamine.sh b/sketchybar/.config/sketchybar/plugins/amphetamine.sh index 7ca30fc..5367317 100755 --- a/sketchybar/.config/sketchybar/plugins/amphetamine.sh +++ b/sketchybar/.config/sketchybar/plugins/amphetamine.sh @@ -15,9 +15,6 @@ if ! pgrep -x Amphetamine >/dev/null 2>&1; then exit 0 fi -# One definition, two call sites. This block was previously written out twice, -# verbatim — once here and once after the click below — and the two copies are -# exactly the kind of thing that drifts the first time either is touched. read_session() { local state state="$(osascript \ @@ -38,8 +35,6 @@ if [ "$SENDER" = "mouse.clicked" ]; then else osascript -e 'tell application "Amphetamine" to start new session with options {duration:0, interval:0, displaySleepAllowed:false}' >/dev/null 2>&1 fi - # Amphetamine updates its state asynchronously; without this the re-read - # below races the toggle and paints the previous state. sleep 0.4 read_session fi diff --git a/sketchybar/.config/sketchybar/plugins/battery.sh b/sketchybar/.config/sketchybar/plugins/battery.sh index 4627ad4..80b468e 100755 --- a/sketchybar/.config/sketchybar/plugins/battery.sh +++ b/sketchybar/.config/sketchybar/plugins/battery.sh @@ -7,15 +7,6 @@ source "$HOME/.config/sketchybar/lib.sh" batt="$(pmset -g batt)" -# Parameter expansion, not `grep | head | tr`. pmset prints e.g. -# Now drawing from 'AC Power' -# -InternalBattery-0 (id=…) 100%; charged; 0:00 remaining present: true -# -# THE NO-BATTERY CASE IS NOT HYPOTHETICAL — it is this machine. A Mac Studio -# prints only the "Now drawing from 'AC Power'" line, with no battery line and no -# percent sign anywhere. So test for '%' FIRST: an earlier version of this parse -# assumed the battery line existed, and on a desktop it happily produced the -# string "Power'" as a percentage. case "$batt" in *%*) pct="${batt%%\%*}" # drop from the '%' onward @@ -24,11 +15,6 @@ case "$batt" in *) pct="" ;; # no battery — the guard below hides the item esac -# CHARGING IS NOT THE SAME AS ON AC, and this used to conflate them. The old test -# was `grep -c "AC Power"`, which is true whenever the cable is in — so a battery -# sitting at 100% on mains was painted green as though it were still charging. -# pmset states its own answer: the status field reads "charging", "charged", -# "discharging" or "AC attached". Only the first is actually charging. case "$batt" in *"; charging"*) charging=1 ;; *) charging=0 ;; @@ -39,17 +25,6 @@ if [ -z "$pct" ]; then exit 0 fi -# THE ONE PLACE THIS BAR LEAVES MATERIAL DESIGN. Every other icon is md-*, but -# all 95 md-battery_* glyphs are VERTICAL — the horizontal series (battery_horiz) -# postdates this Nerd Font build and is not present. Font Awesome's battery set -# is horizontal and happens to have exactly the five steps this script already -# used, so it maps 1:1. Checked by scanning every glyph name in the font, not -# assumed. -# -# CHARGING is carried by colour alone, because Font Awesome has no -# battery-with-bolt glyph. Using the md charging icon here would flip the shape -# between vertical and horizontal as the cable goes in and out, which is worse -# than losing the bolt. The level still shows while charging. if [ "$pct" -ge 80 ]; then icon=""; color="$TEXT" # fa-battery_full elif [ "$pct" -ge 60 ]; then icon=""; color="$TEXT" # fa-battery_three_quarters elif [ "$pct" -ge 40 ]; then icon=""; color="$YELLOW" # fa-battery_half diff --git a/sketchybar/.config/sketchybar/plugins/brightness.sh b/sketchybar/.config/sketchybar/plugins/brightness.sh index f445f51..cb10c68 100755 --- a/sketchybar/.config/sketchybar/plugins/brightness.sh +++ b/sketchybar/.config/sketchybar/plugins/brightness.sh @@ -1,40 +1,5 @@ #!/usr/bin/env bash # Display brightness, for whichever display is MAIN. -# -# TWO DISPLAYS, TWO MECHANISMS, and reading the wrong one is how this item spent -# a long time confidently showing 100%: -# -# BUILT-IN has a real backlight that macOS owns, so DisplayServicesGetBrightness -# reports it truthfully. -# -# EXTERNAL does not. DisplayServicesGetBrightness SUCCEEDS on a non-Apple -# external and returns a hardcoded 1.0 — measured on the Samsung -# here (CGDirectDisplayID 2, builtin=false). It is not stale, it is -# FABRICATED, which is worse: nothing errors and the number looks -# plausible. The old version of this script looped display IDs 1..16 -# and took the first call that succeeded, which on a clamshell desk -# is exactly that lie. -# -# So for an external we read what the DIMMER ACTUALLY DID rather than asking -# macOS. MonitorControl is in software-dimming mode for this display -# (`forceSw(...)=1`, `avoidGamma=0` in app.monitorcontrol.MonitorControl), which -# works by scaling the display's GAMMA RAMP — confirmed by `nm -u` on its binary, -# which imports both _CGSetDisplayTransferByTable and _CGGetDisplayTransferByTable. -# We read the ramp back through the same public API it writes with. -# -# WHY NOT DDC (m1ddc, ddcctl): in software mode MonitorControl never touches the -# monitor's internal DDC brightness, so a DDC read returns an unrelated number -# that happens to look reasonable. It would also cost a ~100-300ms subprocess on -# every poll and put traffic on a bus MonitorControl is already using. -# WHY NOT ASK MonitorControl: it has no .sdef, no NSAppleScriptEnabled, no -# CFBundleURLTypes and no bundled CLI. There is nothing to ask. -# Its `SwBrightness(@)` pref does hold the value, -# but that is a private, unversioned key layout — the gamma ramp is the effect -# itself and is public API. -# -# LIMIT: if MonitorControl is switched to hardware/DDC dimming, the ramp stays -# flat and this reports 100% at every real brightness. That is a knowing trade — -# it is no worse than the behaviour this replaced. source "$HOME/.config/sketchybar/colors.sh" source "$HOME/.config/sketchybar/lib.sh" @@ -81,15 +46,6 @@ n = ctypes.c_uint32() if cg.CGGetDisplayTransferByTable(did, cap, r, g, b, ctypes.byref(n)) != 0 or n.value == 0: sys.exit(1) -# RED, not the max across channels. Colour-temperature shifters (Night Shift, -# f.lux) rewrite this same ramp, but they scale BLUE and some green while -# leaving red near 1.0; brightness dimming scales all three uniformly. Reading -# red therefore reports brightness alone and ignores the colour shift. -# -# The ramp is monotonic, so its last entry is its maximum — the scale factor the -# dimmer applied. A FLAT ramp (1.0) means no dimming, i.e. a genuine 100%, and -# must still be reported; exiting non-zero is reserved for a failed call, which -# is the only case where the level is truly unknown. print(round(r[n.value - 1] * 100)) ' 2>/dev/null)" fi diff --git a/sketchybar/.config/sketchybar/plugins/clock.sh b/sketchybar/.config/sketchybar/plugins/clock.sh index 4767086..a709e11 100755 --- a/sketchybar/.config/sketchybar/plugins/clock.sh +++ b/sketchybar/.config/sketchybar/plugins/clock.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash # 24-hour clock with the date. Updated on a timer from sketchybarrc. -sketchybar --set "$NAME" label="$(date '+%a %d %b %H:%M')" +sketchybar --set "$NAME" label="$(date '+%a %d %b %H:%M:%S')" diff --git a/sketchybar/.config/sketchybar/plugins/music.sh b/sketchybar/.config/sketchybar/plugins/music.sh index 571ceb3..03ef02b 100755 --- a/sketchybar/.config/sketchybar/plugins/music.sh +++ b/sketchybar/.config/sketchybar/plugins/music.sh @@ -1,11 +1,37 @@ #!/usr/bin/env bash -# Apple Music now-playing. Click toggles play/pause. +# Now-playing for ANY player, via macOS's system-wide Now Playing state — the +# same source Control Center shows. Left-click toggles play/pause, right-click +# opens the transport popup. +# +# WHY NOT AppleScript. This used to be Apple-Music-only (`pgrep -x Music`), and +# per-app AppleScript branching was the obvious way to widen it. It cannot work +# here: IINA — the actual music player on this machine — ships no .sdef, leaves +# NSAppleScriptEnabled unset, and its iina-cli is open-only with no way to query +# state. It does link MediaPlayer.framework, so it publishes to Now Playing and +# is visible to MediaRemote and to nothing else. +# +# PRIVATE API, KNOWINGLY. media-control reads MediaRemote, which Apple gated +# behind an entitlement in macOS 15.4 — that is what broke nowplaying-cli. +# Measured on 26.6: dlopen succeeds and MRMediaRemoteGetNowPlayingInfo's callback +# fires from an ad-hoc-signed process, but hands back a NULL dictionary while +# IINA was demonstrably publishing. media-control gets real data from the same +# machine because bin/media-control is `#!/usr/bin/perl` and execs its adapter +# under that interpreter, inheriting APPLE's signature on /usr/bin/perl. +# +# So this depends on a hole Apple has closed once already. If it closes again the +# `null` branch below hides the pill cleanly — no error, no stale label. To +# confirm the tool itself rather than guess: `media-control test` exits non-zero +# when it cannot operate on the running macOS. +# +# --no-artwork is NOT an optimisation of runtime — with and without measured 23 +# and 24ms CPU, inside noise. It is about payload: 414 bytes versus 49,579, since +# the default embeds the cover as base64 JPEG on every single poll. Nothing here +# renders artwork. source "$HOME/.config/sketchybar/colors.sh" source "$HOME/.config/sketchybar/lib.sh" - -pgrep -x Music >/dev/null 2>&1 || hide +require media-control if [ "$SENDER" = "mouse.clicked" ]; then case "$BUTTON" in @@ -14,37 +40,34 @@ if [ "$SENDER" = "mouse.clicked" ]; then exit 0 ;; *) - osascript -e 'tell application "Music" to playpause' >/dev/null 2>&1 + media-control toggle-play-pause >/dev/null 2>&1 sleep 0.3 ;; esac fi -IFS=$'\t' read -r state track artist </dev/null) +# `null` is the documented response when nothing holds a Now Playing session — +# not an error, and the normal state most of the day. Empty output means the +# adapter failed outright; both hide. +info="$(media-control get --no-artwork 2>/dev/null)" +[ -n "$info" ] && [ "$info" != "null" ] || hide + +IFS=$'\t' read -r playing track artist </dev/null) EOF -case "$state" in - stopped|'') hide ;; -esac -[ -n "$track" ] || exit 0 +# A session can exist with no title (a stream still resolving, an app that +# registered before it had metadata). Nothing worth drawing, so hide rather than +# leave the previous track's label sitting there. +[ -n "$track" ] || hide -case "$state" in - playing|"fast forwarding"|rewinding) icon="󰎇"; color="$GREEN" ;; - paused) icon="󰏤"; color="$OVERLAY0" ;; - *) exit 0 ;; -esac +# `playing` is a mandatory key in the adapter's payload — it is never null — so +# this is a real two-state test, not a fallthrough. +if [ "$playing" = "true" ]; then + icon="󰎇"; color="$GREEN" +else + icon="󰏤"; color="$OVERLAY0" +fi if [ -n "$artist" ]; then label="$track — $artist" diff --git a/sketchybar/.config/sketchybar/plugins/pomodoro.sh b/sketchybar/.config/sketchybar/plugins/pomodoro.sh index a1150e4..0a8b0a7 100755 --- a/sketchybar/.config/sketchybar/plugins/pomodoro.sh +++ b/sketchybar/.config/sketchybar/plugins/pomodoro.sh @@ -9,10 +9,6 @@ STATE="$(state_file pomodoro)" WORK_MIN=25 BREAK_MIN=5 -# md-timer, not md-alarm — an alarm bell reads as "alarm clock" and competes with -# the actual clock two pills to its right. Held in a variable because the idle and -# running branches below both draw it, and they drifted apart the moment they -# didn't share one. ICON="󱎫" now=$(date +%s) diff --git a/sketchybar/.config/sketchybar/plugins/system.sh b/sketchybar/.config/sketchybar/plugins/system.sh index 7543d66..9bc9bc7 100755 --- a/sketchybar/.config/sketchybar/plugins/system.sh +++ b/sketchybar/.config/sketchybar/plugins/system.sh @@ -1,26 +1,11 @@ #!/usr/bin/env bash # CPU load, memory, temperature and fan speed. -# -# TWO SOURCES, DELIBERATELY. macmon supplies load, memory and fan RPM from ONE -# sample; the temperature comes from helpers/thermal instead. macmon's -# temp.cpu_temp_avg used to drive this item and is not trustworthy — it is -# bimodal at flat idle and drops as far as 20°C. The full measurement is in the -# header of thermal.swift; the short version is that it is a mean over a varying -# set of sensors, so the bar was faithfully displaying a bogus number. source "$HOME/.config/sketchybar/colors.sh" source "$HOME/.config/sketchybar/lib.sh" require macmon -# ONE SAMPLE, THREE ITEMS: a macmon sample costs ~0.9s, so only `thermals` runs -# this script; it writes cpu and memory too. cpu and memory are passive — no -# script, no update_freq — and would never update on their own. -# -# The leading `ok` field is what distinguishes "macmon failed" from "macmon says -# zero". It has to be explicit: the temperature used to serve as that signal, and -# now that it comes from elsewhere there is nothing else in the row that cannot -# legitimately be 0. IFS=$'\t' read -r ok rpm cpu_pct ram_used ram_pct </dev/null | python3 -c ' import json, sys @@ -32,16 +17,12 @@ except Exception: sys.exit() fans = d.get("fans", []) or [] -# Fans are per-side and rarely equal; the louder one is the one you can hear. rpm = max((f.get("rpm", 0) for f in fans), default=0) if fans else -1 - -# cpu_usage_pct is a RATIO despite the name — measured 0.0558 for 5.6% load. cpu = round((d.get("cpu_usage_pct") or 0) * 100) mem = d.get("memory", {}) or {} total = mem.get("ram_total") or 0 used = mem.get("ram_usage") or 0 -# GiB to one decimal; macmon reports bytes (25769803776 = 24 GiB). gib = used / (1024 ** 3) pct = round(used * 100 / total) if total else 0 @@ -54,10 +35,6 @@ if [ "$ok" != "1" ]; then exit 0 fi -# A missing temperature must NOT hide the island — note this deliberately does -# NOT use lib.sh's require()/hide(). cpu and memory came from macmon and are -# still good; only the reading we could not take goes away. Same rule as -# volume.sh and brightness.sh: never state a value we do not know. temp="" if bin="$(ensure_helper thermal)"; then temp="$("$bin" 2>/dev/null)" @@ -66,9 +43,6 @@ if bin="$(ensure_helper thermal)"; then esac fi -# The helper prints one decimal — precise enough to watch the die move while -# debugging, but the bar shows a whole number. A tenth of a degree is not -# information anyone acts on, and it keeps the pill one character narrower. if [ -n "$temp" ]; then temp="$(printf '%.0f' "$temp")" if [ "$temp" -ge 85 ]; then temp_color="$RED" @@ -91,24 +65,6 @@ elif [ "$ram_pct" -ge 75 ]; then ram_color="$PEACH" else ram_color="$TEXT" fi -# ONE space each side of the fan glyph, not two. This read lopsided because it -# was, and the imbalance is in the glyph's own metrics rather than in the spaces. -# Measured per-glyph ink at label.font 13.5 (FiraCodeNF-Med), which is the face -# sketchybar actually resolves "FiraCode Nerd Font:Medium" to: -# ° ink ends 22.22, right side bearing 2.70 -# 󰈐 advance 8.31 but ink 11.26 wide — ZERO left side bearing, and it -# OVERFLOWS its own cell by ~2.95 on the right -# 1 left side bearing 1.12 -# So the fan glyph hugs whatever precedes it and crowds whatever follows: -# gap before = 2.70 + 8.31 + 0.00 = 11.01pt -# gap after = 8.31 - 2.95 + 1.12 = 6.48pt -# A second space added 8.31 to the gap that was ALREADY the larger one, making -# it 19.32 vs 6.48 — a 3:1 split, which is what the eye caught. -# The residual 4.5pt cannot be closed with space characters: FiraCode is -# monospaced and has no thin/hair space at all (U+2009, U+200A, U+2006 are -# absent; U+2008 exists but is a full 8.31 cell), so reaching for one would only -# trigger the font fallback this config warns about elsewhere. 8.31 is the only -# quantum available, and one is closer than two. if [ -n "$temp" ] && [ -n "$rpm" ] && [ "$rpm" -ge 0 ]; then thermal_label="${temp}° 󰈐 ${rpm}" elif [ -n "$temp" ]; then @@ -120,10 +76,6 @@ else fi sketchybar \ - `# md-speedometer, not md-cpu_64_bit: cpu sits directly beside memory in` \ - `# island.system, and md-cpu_64_bit is a detailed chip that reads as almost` \ - `# the same picture as md-memory at icon sizes (13pt when this was chosen,` \ - `# 14.5 now — still too close to tell apart at a glance).` \ --set cpu drawing=on icon="󰓅" icon.color="$cpu_color" \ label.color="$TEXT" label="${cpu_pct}%" \ --set memory drawing=on icon="󰍛" icon.color="$ram_color" \ diff --git a/sketchybar/.config/sketchybar/plugins/volume.sh b/sketchybar/.config/sketchybar/plugins/volume.sh index c6bb297..6059e60 100755 --- a/sketchybar/.config/sketchybar/plugins/volume.sh +++ b/sketchybar/.config/sketchybar/plugins/volume.sh @@ -1,77 +1,31 @@ #!/usr/bin/env bash # Volume. Driven by sketchybar's native volume_change event ($INFO = the new # percentage), so there is no polling. -# -# NOT EVERY OUTPUT DEVICE HAS A VOLUME. Class-compliant USB interfaces set their -# level in hardware and expose no software control at all — measured on a -# Focusrite Scarlett 2i2 4th Gen, which has neither kAudioDevicePropertyVolumeScalar -# nor kAudioDevicePropertyMute on any scope or element. macOS greys its own slider -# out for these. That is a NORMAL state for such a device, not a failure, and this -# script's job is then to show nothing rather than to invent a number. source "$HOME/.config/sketchybar/colors.sh" source "$HOME/.config/sketchybar/lib.sh" -# ONE osascript call for both readings. This used to be two — one for the level -# and one for the mute state — which is how the bug below survived: the two code -# paths disagreed about what an unreadable device looked like. settings="$(osascript -e 'get volume settings' 2>/dev/null)" -# "output volume:50, input volume:100, alert volume:100, output muted:false" -# Parameter expansion rather than sed/awk, to keep this to a single subprocess. -# `#*output volume:` is safe despite "alert volume" also containing "volume" — -# it matches the shortest prefix, and "output volume" is the first field. vol="${settings#*output volume:}" vol="${vol%%,*}" muted="${settings##*output muted:}" -# THE READABILITY TEST, AND IT RUNS BEFORE $SENDER IS CONSULTED. Two traps, both -# measured rather than guessed: -# -# 1. osascript prints the literal string "missing value" and EXITS 0 for a -# device with no software volume. So the value is not empty, and the obvious -# `[ -z "$vol" ]` guard sails straight past it — which is exactly how this -# item came to render "missing value%" on the bar. -# -# 2. $INFO CANNOT BE TRUSTED HERE EITHER, so this test must not be skipped on -# the volume_change path. sketchybar's own handler (src/volume.c) declares -# `float volume_main = 0.f`, ignores the return of AudioObjectGetPropertyData, -# and posts the untouched 0 when the read fails. device_changed() calls that -# handler on every default-output-device switch, so switching TO such a -# device delivers $INFO=0 — which would render as a confident "0%". That is -# worse than the visible garbage it replaced, because it looks plausible. -# -# An unreadable volume is not a volume of zero. The bar never states a level it -# does not actually know — the same rule thermals follows in system.sh. case "$vol" in '' | *'missing value'*) - # See `updates=on` on this item in sketchybarrc: under the config-wide - # when_shown default a hidden item stops receiving its subscribed events, - # so this line would be a ONE-WAY DOOR and plugging in headphones later - # could never bring the item back. sketchybar --set "$NAME" drawing=off exit 0 ;; esac -# Only now is the fast path safe: the device is known to have a readable volume, -# so a volume_change event's $INFO is a real percentage and is fresher than the -# reading above. if [ "$SENDER" = "volume_change" ] && [ -n "$INFO" ]; then vol="$INFO" fi -# A device can report a level but no mute state; treat that as not muted rather -# than letting "missing value" fall through the string comparison by accident. case "$muted" in *'missing value'*) muted="false" ;; esac -# Muted reads 0%, not "muted": it is the same quantity as every other state of -# this item rather than a different kind of thing, so it lines up with the -# neighbouring percentages instead of making the pill jump width. The struck-out -# speaker icon is what carries "muted" — note this branch also catches a genuine -# 0% that is not muted, which is why the icon does the work and the text does not. if [ "$muted" = "true" ] || [ "$vol" -eq 0 ] 2>/dev/null; then sketchybar --set "$NAME" drawing=on icon="󰖁" icon.color="$OVERLAY0" \ label.color="$OVERLAY0" label="0%" diff --git a/sketchybar/.config/sketchybar/plugins/vpn.sh b/sketchybar/.config/sketchybar/plugins/vpn.sh index c99bda4..79f4c49 100755 --- a/sketchybar/.config/sketchybar/plugins/vpn.sh +++ b/sketchybar/.config/sketchybar/plugins/vpn.sh @@ -18,7 +18,5 @@ fi name="$(truncate_label "$name" "$MAX_LEN")" sketchybar --set "$NAME" drawing=on \ - `# md-vpn, not md-shield_lock — a shield reads as firewall/password manager` \ - `# /antivirus just as easily. MDI has a dedicated VPN glyph.` \ icon="󰖂" icon.color="$GREEN" \ label.drawing=on label.color="$TEXT" label="$name" diff --git a/sketchybar/.config/sketchybar/plugins/weather.sh b/sketchybar/.config/sketchybar/plugins/weather.sh index 67e4002..8060928 100755 --- a/sketchybar/.config/sketchybar/plugins/weather.sh +++ b/sketchybar/.config/sketchybar/plugins/weather.sh @@ -1,18 +1,5 @@ #!/usr/bin/env bash # Current conditions from wttr.in. -# -# PRIVACY — read this before copying the file. With LOCATION empty, wttr.in -# GEOLOCATES BY SOURCE IP: every poll discloses this host's public IP to a third -# party and returns location-correlated data. That is a deliberate trade for -# zero configuration, but it is not obvious from the URL, and sketchybarrc's -# comment on this item points here for exactly this paragraph. -# -# Three ways to change it: -# SKETCHYBAR_WEATHER_LOCATION="Copenhagen" send an explicit place instead — -# still a third-party request, but no -# IP-based inference -# SKETCHYBAR_WEATHER_LOCATION="off" disable the item entirely -# (unset) current behaviour, IP geolocation source "$HOME/.config/sketchybar/colors.sh" source "$HOME/.config/sketchybar/lib.sh" @@ -27,9 +14,6 @@ if [ "$LOCATION" = "off" ]; then exit 0 fi -# -f so an HTTP error is a FAILURE rather than a body. Without it curl exits 0 -# on a 5xx and the error page lands in the cache, which then gets served as the -# last-known-good reading indefinitely. reading="$(curl -sf --max-time 5 "https://wttr.in/${LOCATION}?format=%C|%t" 2>/dev/null)" case "$reading" in diff --git a/sketchybar/.config/sketchybar/plugins/wifi.sh b/sketchybar/.config/sketchybar/plugins/wifi.sh index 67fdce6..4cfd047 100755 --- a/sketchybar/.config/sketchybar/plugins/wifi.sh +++ b/sketchybar/.config/sketchybar/plugins/wifi.sh @@ -5,11 +5,6 @@ source "$HOME/.config/sketchybar/colors.sh" source "$HOME/.config/sketchybar/lib.sh" -# CACHED, because the device name does not change between reboots and the lookup -# is the most expensive thing in this script: `networksetup -listallhardwareports` -# measured 40ms, and it ran on every single invocation to rediscover a constant. -# $TMPDIR is cleared by macOS, so the cache re-warms on reboot, which is exactly -# when the answer could legitimately differ. DEV_CACHE="$(state_file wifi-dev)" if [ -s "$DEV_CACHE" ]; then read -r dev < "$DEV_CACHE" diff --git a/sketchybar/.config/sketchybar/plugins/workspaces.sh b/sketchybar/.config/sketchybar/plugins/workspaces.sh index 189d228..67ed0fe 100755 --- a/sketchybar/.config/sketchybar/plugins/workspaces.sh +++ b/sketchybar/.config/sketchybar/plugins/workspaces.sh @@ -1,42 +1,17 @@ #!/usr/bin/env bash # Repaints EVERY workspace pill from ONE pass. Driven by the spaces.driver item. -# -# WHY THIS REPLACED aerospace.sh, which was per-item: -# there is one sketchybar item per workspace (space.1 .. space.9), and each one -# used to run its own copy of the plugin, and each copy shelled out to -# `aerospace list-workspaces --monitor all --empty no` to ask a GLOBAL question — -# which workspaces have windows — whose answer is identical for all nine. -# -# Measured: 25ms per aerospace call, 104ms for nine sequential calls, plus nine -# `grep` subprocesses. And the items were subscribed to front_app_switched as -# well as aerospace_workspace_change, so that ran on every APPLICATION SWITCH, -# which is one of the highest-frequency actions a user performs. -# -# This does 2 aerospace calls and 1 sketchybar call, total, however many -# workspaces exist. The occupancy test is parameter expansion rather than a -# `grep` per item. source "$HOME/.config/sketchybar/colors.sh" source "$HOME/.config/sketchybar/lib.sh" require aerospace -# FOCUSED_WORKSPACE is set by aerospace's exec-on-workspace-change; fall back to -# a query for the startup pass and for front_app_switched, which does not carry it. focused="${FOCUSED_WORKSPACE:-$(aerospace list-workspaces --focused 2>/dev/null)}" - -# Sentinel spaces on both ends so the substring test below cannot match a prefix. -# Workspaces are single digits today, so "1" could never be found inside "10" — -# but the guard is free and this is exactly the bug that appears the day someone -# adds a tenth workspace. occupied=" $(aerospace list-workspaces --empty no 2>/dev/null | tr '\n' ' ')" args=() for sid in $(aerospace list-workspaces --all 2>/dev/null); do if [ "$sid" = "$focused" ]; then - # The mauve pill. This is the only place background.drawing is turned on - # for a workspace item; the bracket renders below its members, so the - # pill draws on top of the island rather than being hidden by it. args+=(--set "space.$sid" background.drawing=on label.color="$CRUST" icon.color="$CRUST") elif [ "${occupied#* $sid }" != "$occupied" ]; then @@ -48,7 +23,4 @@ for sid in $(aerospace list-workspaces --all 2>/dev/null); do fi done -# One call. Nine --set clauses in a single message is dramatically cheaper than -# nine invocations, and it repaints atomically so the pill never appears on two -# workspaces at once mid-update. [ ${#args[@]} -gt 0 ] && sketchybar "${args[@]}" diff --git a/sketchybar/.config/sketchybar/sketchybarrc b/sketchybar/.config/sketchybar/sketchybarrc index 98e2efa..1f1f59b 100755 --- a/sketchybar/.config/sketchybar/sketchybarrc +++ b/sketchybar/.config/sketchybar/sketchybarrc @@ -4,66 +4,22 @@ # ============================================================ # Docs: https://felixkratz.github.io/SketchyBar/ # -# WHY THIS EXISTS: with the Dock auto-hidden and nine numeric workspaces, there -# was no way to see which workspaces held windows without switching to each one -# to look. The workspace items on the left are the point; everything else is -# filler. +# ADD ORDER IS THE LAYOUT, and it runs in opposite directions on the two sides: +# left items render in add order (first added sits furthest LEFT), right items +# render in reverse (first added sits furthest RIGHT). So the right-hand half of +# this file reads as the mirror of what is on screen. Moving an item between +# sides means reversing its position in the file. # -# Started by aerospace's after-startup-command, next to borders — not by -# `brew services`, so the bar's lifetime is tied to the window manager's. -# To poke at it by hand: `pkill -x sketchybar` then `sketchybar &`. -# -# ONE SCREEN. This config is tuned for a single 2560x1440 external and carries -# no notch arithmetic, no per-display scoping and no width budget. It used to: -# on a 1512pt laptop the notch split the bar into two ~655pt runs and the right -# one was genuinely oversubscribed, which drove several decisions here. All of -# that was removed. If a notched display ever comes back, the measurements are -# in git history — do not re-derive them. -# -# SHAPE: the bar itself is transparent. The brackets at the bottom of this file -# are the only backgrounds drawn — three islands on the left (workspaces, front -# app, music) and four on the right (system, status, network, hardware, time). -# They are frosted individually; the bar behind them is not. +# EVERYTHING HERE FAILS SILENTLY. A broken item does not error — it is simply +# absent, or frozen at its last value, and the bar comes up looking fine. The +# four traps that cause this are in CLAUDE.md; `scripts/lint-sketchybar.sh` +# catches the three that are checkable statically. Run it after editing. # -# Nothing is placed in the CENTRE. That is now a layout preference rather than a -# constraint: the runs hug the edges and the middle stays clear, which is what -# makes the bar read as two groups instead of one long strip. -# -# GEOMETRY, measured on this display. Only two things move: island.system with -# the fan RPM digit count, and island.app with the focused app's name (front_app -# applies no MAX_LEN, so it is ~8.1pt per character plus 24). -# island.spaces 8..195 187pt island.system 1783..2013 230pt -# island.app 207..264 57pt island.status 2025..2157 132pt -# island.media (hidden) 192pt island.network 2169..2275 106pt -# island.hardware 2287..2365 78pt -# island.time 2377..2552 175pt -# ~1200pt of dead space between the runs, so nothing here is width-constrained. -# Add what you like; just keep the middle clear. -# -# HEIGHT vs. GAPS — three settings that must move together: -# 1. this bar's height (38). A legibility choice, not a measured constant. -# 2. the islands' `background.height` (32), centred in that 38 — so their -# bottom edge lands at y=35. -# 3. `gaps.outer.top` in aerospace.toml: 43, i.e. 35 + the usual 8pt gap. Get -# this wrong and windows either slide under the islands or leave a dead -# strip below them. -# ...and the macOS menu bar auto-hidden (`_HIHideMenuBar`, set by the install -# script). With it visible it reserves ~30pt and pushes this bar down below -# it rather than to the top of the screen. -# Change the height and 43 stops matching. +# To poke at it by hand: `pkill -x sketchybar` then `sketchybar &`. CONFIG_DIR="$HOME/.config/sketchybar" PLUGIN_DIR="$CONFIG_DIR/plugins" -# Plugins shell out to `aerospace` and `sketchybar`. Set PATH explicitly so the -# bar behaves the same whether it was launched from a terminal or by launchd, -# which starts with a minimal PATH. -# BOTH Homebrew prefixes: /opt/homebrew on Apple Silicon, /usr/local on Intel. -# Prepending a directory that does not exist is harmless, and this avoids paying -# for a `brew --prefix` subprocess every time the bar starts. Without the Intel -# entry every plugin depending on aerospace/macmon/gh/icalBuddy would silently -# degrade on an Intel Mac — they all hide rather than error when a binary is -# missing, so the bar would come up looking merely quiet. export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:$PATH" source "$CONFIG_DIR/colors.sh" @@ -71,20 +27,13 @@ source "$CONFIG_DIR/colors.sh" FONT="FiraCode Nerd Font" # --- Bar --------------------------------------------------- -# Transparent: the islands at the bottom of this file draw everything visible. -# DO NOT put a colour or a blur here. A full-width frosted band was tried and -# rejected — blur_radius on the BAR frosts the whole rectangle, gaps included, -# which is the effect the islands exist to avoid. The pills carry their own blur -# instead; see ISLAND_STYLE. -# padding 8 matches aerospace's gaps.outer.left/right, so the islands line up -# with the window column underneath. +# The bar itself draws NOTHING — it is transparent, and the brackets at the +# bottom of this file are the only backgrounds on screen. That is what makes the +# islands read as separate frosted pills rather than one long strip. # -# The notch properties (notch_width, notch_display_height, notch_offset) were -# set here and are gone — they only do anything on a display with a notch, and -# there is not one. Worth knowing if they ever come back: NONE of them are echoed -# by `sketchybar --query bar`, the same silent-property trap as label.max_chars -# on the music item, so a wrong value looks exactly like a right one. The only -# feedback is that an invalid property errors on --bar. +# height=38 IS COUPLED TO aerospace.toml. `gaps.outer.top = 43` there is this +# height plus 5pt of clearance; change one without the other and windows either +# tuck under the bar or leave a visible band. sketchybar --bar \ height=38 \ position=top \ @@ -95,186 +44,47 @@ sketchybar --bar \ shadow=off # --- Item defaults ----------------------------------------- -# SPACING CONTRACT — every gap between two items comes out at 12pt. -# The gap is the trailing padding of one item plus the leading padding of the -# next, so 6 on each OUTER edge (icon.padding_left, label.padding_right) gives -# 12; the INNER 3+3 only separates an icon from its own label. Two ways it -# drifts, both of which this config has been bitten by: -# - an item that overrides an OUTER padding changes the gap. Override the -# inner ones instead, or the pill/background paddings. -# - a hidden label contributes nothing at all, its padding included, so the -# trailing edge falls back to icon.padding_right (3) and the gap collapses -# to 9. An item that hides its label must set icon.padding_right=6 while -# hidden — see amphetamine.sh and bluetooth.sh. -# - islands swallow padding at a group boundary, in both directions. A -# bracket's rect runs from the outer edge of its first member's -# background.padding_left to the outer edge of its last member's -# background.padding_right. Two consequences, both measured: -# (a) you CANNOT open a gap between two islands with item padding — both -# sides get absorbed as interior padding and the islands still touch. -# Space between islands comes only from an item that belongs to -# neither bracket — spacer.system and spacer.media below. -# (b) interior padding has to come from something INSIDE the bracket — -# either a dedicated pad item (what the islands use, see ISLAND -# PADDING) or background.padding_* on the first/last member. Both grow -# the island inward, leave the outer edge pinned to the bar padding, -# and leave every 12pt gap — inter-item and inter-island — untouched. -# background.padding_* additionally does not resize the member's own -# background, so it cannot distort a member's pill. -# Setting background.padding_* on the BRACKET itself does nothing at all; -# group bounds are computed purely from the members. -# -# ISLAND PADDING — 12pt of interior clearance at each island end, built as a -# dedicated 6pt PAD ITEM inside the bracket plus the 6pt outer padding of the -# end member beside it. 6 alone read cramped against the islands' 13pt corner -# radius, which is why it is 12. -# -# THE PAD ITEMS ARE THE POINT, and the obvious alternative is a trap. Setting -# background.padding=6 on an island's first and last member is what this config -# used to do, and it is broken by construction: per the bullet above, a hidden -# item contributes NOTHING — its background.padding included — so the moment an -# end member hides, a different item becomes the end, carries no -# background.padding, and the interior silently halves to 6. Measured on the -# running bar before the fix, in the ordinary state where calendar, vpn and -# weather are all hidden: -# island.time 6pt left / 12pt right island.hardware 12 / 12 (ok) -# island.network 6pt left / 12pt right -# island.status 12pt left / 6pt right -# That is the COMMON case, not an edge case — calendar is hidden for most of the -# day, vpn whenever nothing is connected, weather until it first fetches. A pad -# item has no script and never hides, so the interior stays 6+6 whatever else -# comes and goes, with no per-plugin bookkeeping. -# -# THE RULE: an island that must NEVER COLLAPSE gets pad items; an island that -# must be ABLE to collapse keeps background.padding on its end members, because -# a pad would never hide and would strand an empty pill. Only system and media -# collapse — see the note at their brackets. -# spaces inset.spaces.l / inset.spaces.r network inset.network.l / .r -# time inset.time.l / inset.time.r status inset.status.l / .r -# hardware inset.hardware.l / inset.hardware.r -# system cpu / thermals background.padding — collapses -# media music, both ends background.padding — collapses -# -# Verified against the running bar rather than assumed: a bracket DOES include a -# member whose icon, label and background are all drawing=off, and sizes it from -# its `width`. A three-item test bracket (6pt pad, 42pt item, 6pt pad) measured -# 54pt, with 12pt from each bracket edge to the label's ink. +# `updates=when_shown` below is the reason `updates=on` appears a dozen times in +# this file. Under it, a hidden item stops being updated ENTIRELY — so any item +# whose script can hide itself would run once, hide, and never be polled again. +# Every self-hiding item therefore overrides it. See CLAUDE.md trap 1; the linter +# checks this one automatically. sketchybar --default \ updates=when_shown \ icon.font="$FONT:Medium:14.5" \ icon.color="$TEXT" \ icon.padding_left=6 \ icon.padding_right=3 \ - `# OPTICAL ALIGNMENT. Icons look ~2 physical pixels low next to their text.` \ - `# The icons are innocent: measured with CoreText against this exact face,` \ - `# every Nerd Font glyph lands dead centre (0.00pt) while the LABELS ride` \ - `# high — 'Fri 31 Jul 23:26' by +1.06pt, '65%' by +0.54pt.` \ - `# Cause: text_calculate_bounds centres each run by FONT METRICS, not ink —` \ - `# origin.y = y - (line.ascent - line.descent) / 2` \ - `# The numbers below were measured at the ORIGINAL 13.0/12.0 sizes. They` \ - `# still hold in shape at 14.5/13.5 — the offset scales with the point size,` \ - `# so the ideal is now ~1.125 and 1 is still the nearest integer.` \ - `# The 12pt face reports ascent 11.08 / descent 3.69, so the box reserves` \ - `# descender room. A label with no descender fills only the top of that box,` \ - `# and metric-centring lifts it. Nerd Font's icons are drawn centred on the em` \ - `# box, so they land where the formula intends.` \ - `# NOT a size mismatch: the ratios are identical at both sizes` \ - `# (11.08/12 = 12.00/13 = 0.923), which also proves no font fallback.` \ - `# +1 raises the icons to meet the text. It is applied to the ICONS rather` \ - `# than the labels on purpose — the workspace items are label-only` \ - `# (icon.drawing=off), so a label offset would shift the workspace numbers` \ - `# inside their mauve pill and move every popup label too.` \ - `# The residual is content-dependent and unfixable by a constant: ink bounds` \ - `# follow the glyphs while the metric box does not, so +1 lands 0.06pt off a` \ - `# tall label and 0.46pt off '65%'. Both are better than before; ~1 physical` \ - `# pixel of spread is inherent. Set 0 to restore the old behaviour exactly.` \ icon.y_offset=1 \ label.font="$FONT:Medium:13.5" \ label.color="$TEXT" \ label.padding_left=3 \ label.padding_right=6 \ - `# The focused-workspace pill (aerospace.sh is the only thing that turns` \ - `# this on). radius is half of height, which is what makes it a pill and` \ - `# not a rounded rectangle; 26 inside the islands' 32 insets it by 3.` \ background.corner_radius=13 \ background.height=26 \ background.color="$MAUVE" \ background.drawing=off # --- Invisible width --------------------------------------- -# One idiom, two uses: a 12pt SPACER between two islands (spacer.*), and a 6pt -# INSET inside one (inset.*). Both are ordinary items with nothing drawn, so -# `width` is the whole of them — an item with icon, label and background all off -# has no natural width. -# -# Note this is NOT the same as item-level drawing=off. A hidden item contributes -# no width at all; these are visible items that happen to paint nothing, which is -# exactly why they hold their space and never disappear. -# -# NAMING TRAP — DO NOT name a bracket member `p.`. sketchybar -# parses the member list of `--add bracket` through the same code as an item's -# POSITION argument, and its popup check is loose enough that any token starting -# with `p` and containing a dot is taken for `popup.`. These items were -# called `pad..` first, and every bracket silently failed to exist: -# [!] Add (Popup) island.time: Item 'time.r' is not a valid popup host -# [!] Set: Item not found 'island.time' -# The bar comes up with no pills at all and the only clue is on stderr, which -# nothing reads when aerospace launches this. Probed against the running bar: -# `pad.` `pads.` `pa.` `p.` `plate.` all fail; `inset.` `xad.` `zzpad.` and -# `pad_zz_l` are all fine. `pomodoro` is safe only because it has no dot in it. +# Draws nothing but still occupies its width — which `drawing=off` would not, as +# that contributes no width at all. Two distinct jobs use it, and the difference +# matters because a bracket's rect runs to the outer edge of its end members: +# spacer.* width 12, OUTSIDE every bracket — the only way to open a gap +# BETWEEN two islands. Item padding cannot do it; the islands would +# absorb it and still touch. +# inset.* width 6, INSIDE a bracket — interior padding at the island's ends. BLANK_STYLE=(icon.drawing=off label.drawing=off background.drawing=off) # --- Workspaces -------------------------------------------- -# AeroSpace pushes this event from exec-on-workspace-change; sketchybar has no -# way to learn about a workspace switch on its own. +# ONE DRIVER, NINE PILLS. `spaces.driver` is a zero-width item with the only +# script here; it repaints every space.N pill from three `aerospace` queries per +# run (workspaces.sh). The pills themselves have no script and no update_freq. +# +# Nothing tells SketchyBar that a workspace changed, so aerospace.toml's +# `exec-on-workspace-change` triggers the custom event added below. Without that +# line in the other file, this section is inert. sketchybar --add event aerospace_workspace_change -# The names are collected as they're added so the bracket at the bottom can -# reuse them. Calling `aerospace list-workspaces --all` a second time down there -# would be a second source of truth, and a bracket that names a nonexistent item -# only warns — it doesn't fail — so the drift would be silent. -# Plain digits, which is the workspace name straight from aerospace — no mapping -# table, nothing to keep in sync. -# -# Alternatives were tried and measured; if you revisit this, the numbers are: -# plain digits ink 5.29–5.98 island 182pt -# md-roman_numeral_* ink 2.02–10.50 island 182pt (U+F1088..F1090, I..IX) -# Elder Futhark runes ink 7.45–12.21 island 185pt — NOT in this font, they -# fall back to Apple Symbols -# Island totals barely differ, so this is purely a legibility call, and digits -# win it for the one item whose entire job is telling workspaces apart at a -# glance. They also have the tightest ink spread, so the focused pill stays the -# same size on every workspace — roman numerals swing from 2pt (I) to 10.5pt -# (VIII) and the pill visibly resizes as you move around. -# -# Three traps if you do try glyphs again, all of which bit during that attempt: -# 1. Verify the glyph NAME, not just that the codepoint exists. Presence and -# width say nothing about which picture you get — a guessed base codepoint -# put md-reminder, md-salesforce and md-tractor on workspaces 1, 3 and 7. -# Read names with CGFont.name(for:). -# 2. Names can mislead too: U+F1088/U+F108C report as "alpha-i"/"alpha-v" but -# really are I and V. Render to a PNG and look. -# 3. Measure INK, not the typographic advance. text_prepare_line sizes items -# with kCTLineBoundsUseGlyphPathBounds and Nerd Font glyphs are drawn wider -# than their cell, so a "monospaced" 7.38 advance means nothing. -# ONE DRIVER REPAINTS ALL NINE PILLS. The workspace items carry no script and no -# subscription of their own; this invisible item owns both and rewrites every -# pill in a single sketchybar call — see plugins/workspaces.sh. -# -# Each pill used to run its own copy of the plugin, and each copy shelled out to -# `aerospace list-workspaces` to ask a question whose answer is the same for all -# nine. Measured 25ms per call, 104ms for the nine, plus nine greps — and because -# they were also subscribed to front_app_switched, all of that ran on every -# application switch. Now it is 2 aerospace calls and 1 sketchybar call. -# -# width=0 so it takes no space. It is BLANK_STYLE (visible, paints nothing) -# rather than drawing=off, because a hidden item is not laid out at all — and it -# needs updates=on for the same reason every self-hiding item does: an item that -# is not drawn is not updated under the config-wide when_shown default. -# -# It sits OUTSIDE island.spaces deliberately. A bracket's rect is the union of -# its members, so a zero-width member would not change the pill, but keeping the -# driver out of the group keeps "what the island contains" honest. sketchybar \ --add item spaces.driver left \ --subscribe spaces.driver aerospace_workspace_change front_app_switched \ @@ -293,12 +103,8 @@ for sid in $(aerospace list-workspaces --all); do --set "space.$sid" \ label="$sid" \ icon.drawing=off \ - `# 6/6 keeps the 12pt contract (the icon is off, so the label's` \ - `# padding is the outer edge). It doubles as the pill's interior.` \ label.padding_left=6 \ label.padding_right=6 \ - `# click_script only. The colours come from spaces.driver above; an` \ - `# item needs no script of its own to be repainted by another item.` \ click_script="aerospace workspace $sid" space_items+=("space.$sid") done @@ -308,270 +114,61 @@ sketchybar \ --set inset.spaces.r "${BLANK_STYLE[@]}" width=6 # --- Front app --------------------------------------------- -# --- Front app --------------------------------------------- -# Name of the focused app. -# -# ORDER IS THE LAYOUT on this side too, but the other way round from the right: -# left items render in ADD order, so first added sits furthest LEFT. These two -# blocks therefore have to stay between the workspace loop above and the -# now-playing block below to read [spaces] [app] [media]. (The note that used to -# live here said "and the system metrics below" — those are on the right now.) -# -# Note the spacer: a bracket's rect runs to the outer edge of its first and last -# member, so an item INSIDE a group can never open a gap between groups — only an -# item outside both can. BLANK_STYLE draws nothing but keeps its width, which is -# the point — an item with drawing=off contributes no width at all and the -# islands would touch. +# End of the left run — nothing is added `left` after this. Its island breathes +# with the app name (no max_chars), which is the only variable width on this side. sketchybar \ --add item spacer.apps left \ --set spacer.apps "${BLANK_STYLE[@]}" width=12 -# THIS ISLAND CANNOT COLLAPSE, so background.padding on the sole member is safe. -# front_app.sh always sets a label — it falls back to "—" when aerospace cannot -# name the focused window — and never sets drawing=off, so island.app is always -# drawn and spacer.apps can never be orphaned. -# -# The usual objection to background.padding (see ISLAND PADDING at the top) is -# that a hidden end member takes its padding with it and the interior silently -# halves. That cannot happen here: with ONE member there is no other item that -# could become the end, and if it ever did hide the whole bracket would go with -# it. Pad items would buy nothing and cost two more items. -# -# WIDTH IS UNBOUNDED, unlike every other island on this run. front_app.sh applies -# no MAX_LEN, so the pill tracks the app name: ~8.1pt per character plus 12pt of -# label padding and 12pt of interior. "kitty" ~64pt, "Google Chrome" ~129pt, -# "IntelliJ IDEA Ultimate" ~202pt. There is ~1200pt of clear space to its right -# so none of that matters here — but it IS the only thing on this side that -# moves, and a MAX_LEN in front_app.sh is the lever if it ever reads too wide. sketchybar \ --add item front_app left \ --subscribe front_app front_app_switched \ --set front_app \ icon.drawing=off \ label.color="$SUBTEXT0" \ - `# The icon is off, so the label's padding IS the outer edge — 6/6 keeps` \ - `# the 12pt spacing contract. padding_right is already 6 by default.` \ label.padding_left=6 \ - `# Sole member of its island, so it carries both ends' padding.` \ background.padding_left=6 \ background.padding_right=6 \ script="$PLUGIN_DIR/front_app.sh" -# --- System metrics ---------------------------------------- -# MOVED TO THE RIGHT SIDE — see the SYSTEM block at the end of the right-side -# sequence below. The left run is now just the workspaces and the music island. -# --- Now playing ------------------------------------------- -# Apple Music now-playing, in its own island so it can vanish cleanly: a bracket -# whose every member is hidden goes to g_nirvana and draws nothing, leaving no -# empty pill behind. Right-click opens a transport popup, which costs no bar -# width — that is why prev/next are not inline items. +# --- Service mode indicator -------------------------------- +# A modal pill for AeroSpace's service mode (entered with alt-shift-semicolon). +# CENTER, so it sits apart from the left/right runs and add-order does not matter. # -# spacer.media now separates this island from island.spaces rather than from -# island.system, which moved right. It is still needed for exactly the same -# reason: a bracket's rect runs to the outer edge of its end members, so only an -# item belonging to NEITHER bracket can open a gap between two islands. -sketchybar \ - --add item spacer.media left \ - --set spacer.media "${BLANK_STYLE[@]}" width=12 - +# Hidden by default (drawing=off) so island.mode draws nothing; AeroSpace toggles +# it from the bindings THEMSELVES, not a callback, because on-mode-changed carries +# no mode name (AeroSpace issue #390) — see aerospace.toml's service-mode section. +# +# No script and no update_freq: it is driven purely by `--set` from aerospace.toml, +# so the when_shown/updates=on trap does NOT apply here. That trap is only for an +# item that must run its OWN script to un-hide; this one is written from outside. sketchybar \ - --add item music left \ - --subscribe music mouse.clicked \ - --set music \ - `# SELF-HIDING ITEM -> updates=on. music.sh calls hide() whenever Apple` \ - `# Music is not running, which is the NORMAL state, so this item spends` \ - `# most of its life hidden. Without this it stops being polled the first` \ - `# time it hides and the pill never comes back when you start playing.` \ - `# This was the ninth item with that bug and the only one a hand audit` \ - `# missed: its hide() was a local function, so a grep for a literal` \ - `# drawing=off on NAME never saw it. scripts/lint-sketchybar.sh found it,` \ - `# which is the argument for having the linter at all.` \ - updates=on \ - `# Alone in its island, so it carries both ends' padding. Like` \ - `# island.system this stays on background.padding rather than pad items,` \ - `# because island.media exists precisely to vanish and a pad would keep` \ - `# an empty pill on screen. See ISLAND PADDING at the top.` \ - background.padding_left=6 \ - background.padding_right=6 \ - `# FIXED WIDTH + SCROLLING TEXT. island.media is the same size whatever is` \ - `# playing, so the left run does not reflow every time the track changes` \ - `# — which is the real win, since island.app beside it already moves with` \ - `# the app name. It replaces a MAX_LEN cut in music.sh, which capped` \ - `# CHARACTERS against a budget measured in POINTS and so overflowed on` \ - `# wide titles and wasted room on narrow ones.` \ - `#` \ - `# FOUR properties, and missing any one of them fails quietly:` \ - `# label.max_chars THE ONE THAT DRIVES IT. sketchybar scrolls text` \ - `# "truncated by the max_chars property" — clipping by` \ - `# label.width alone does NOT start a scroll, it just` \ - `# cuts the text off. Note it is label-scoped: a bare` \ - `# item-level max_chars is rejected outright.` \ - `# scroll_texts enables the animation. Off by default.` \ - `# label.width fixes the visible text box, so the pill is the same` \ - `# size whatever is playing. Without it the label` \ - `# renders at its natural extent — measured, the` \ - `# bracket spanned 303pt even with item width= set,` \ - `# because group bounds come from the label, not from` \ - `# the item.` \ - `# width pins the pill so short titles don't shrink it.` \ - `#` \ - `# label.max_chars is NOT echoed by \`sketchybar --query\`, so a missing or` \ - `# misspelled value looks identical to a correct one. Invalid properties do` \ - `# error on --set, though, which is the only feedback available here.` \ - `#` \ - `# THE RULE, established by measurement: the max_chars-truncated text must` \ - `# FIT INSIDE label.width. Give the box less room than the text needs and` \ - `# sketchybar clips instead of animating — the pill looks right and sits` \ - `# perfectly still. It is not "scroll when the text overflows"; it is the` \ - `# other way round, and getting it backwards costs an hour.` \ - `#` \ - `# THE GATE, in src/text.c:277 — text_animate_scroll() opens with` \ - `# if (has_const_width && custom_width < width) return false;` \ - `# where \`width\` is the max_chars-TRUNCATED width and \`custom_width\` is` \ - `# label.width. So label.width must be >= the truncated text, and when it` \ - `# is not you get a still, clipped label rather than any error.` \ - `#` \ - `# Truncated ink per max_chars, MEASURED AGAINST THE RUNNING BAR at` \ - `# label.font 13.5 — a probe item with label.width dynamic and a long` \ - `# label, reading back the item width and subtracting its 9pt of label` \ - `# padding (icon.drawing=off, so the icon contributes nothing):` \ - `# chars 10 12 13 14 16 18 20` \ - `# points 83 92 108 116 133 142 166` \ - `# ~8.1pt per character, because FiraCode is MONOSPACED — every 16-char` \ - `# string lands within a few pt of every other (measured across real` \ - `# titles: 123, 131, 132, 134). The small jitter is side bearings at the` \ - `# truncation point, not content width.` \ - `#` \ - `# 16 chars = 133pt in a 146pt box: 13pt of slack, so a title of unusually` \ - `# wide glyphs still fits and keeps scrolling. That slack is deliberate —` \ - `# clipping is the failure mode, so err generous.` \ - `#` \ - `# THIS TABLE REPLACED AN EARLIER ONE THAT WAS WRONG, and the way it was` \ - `# wrong is worth keeping. It read` \ - `# chars 10 12 13 14 16 18 20` \ - `# points 56 71 71 86 100 115 115` \ - `# — note 12 and 13 tie, and 18 and 20 tie. A monospaced face cannot do` \ - `# that; the plateaus are a fixed-width floor clamping the reading, which` \ - `# is exactly the trap its own note warned about and then fell into. It` \ - `# undersold 16 chars by ~33pt, which is how both 112 and its scaled` \ - `# successor 126 came to be set BELOW the text they had to contain. This` \ - `# item most likely never scrolled until 146.` \ - `#` \ - `# MEASURE AGAINST FiraCodeNF-Med, NOT "FiraCode Nerd Font Medium". That` \ - `# name does not resolve: CoreText silently falls back to HELVETICA and` \ - `# only maps the Nerd Font PUA glyphs to FiraCodeNF-Reg, so a measurement` \ - `# taken that way is of the wrong face entirely and no error is raised.` \ - `# The tell is the one above — a proportional face makes 16-char strings` \ - `# range wildly (47..204pt was the bogus reading) where a monospaced one` \ - `# cannot. Better still, measure the running bar with a probe item as the` \ - `# table above does, which cannot pick the wrong font by construction.` \ - `#` \ - `# THE TABLE IS FONT-DEPENDENT, so RETUNING label.font BREAKS THIS ITEM` \ - `# unless label.width moves with it: the points scale linearly with the` \ - `# point size while max_chars does not.` \ - `#` \ - `# 180 is a comfort choice, not a constraint — the left run ends around` \ - `# x=456 with ~1200pt clear to its right, so there is plenty of headroom` \ - `# to widen this (raise max_chars AND label.width together, per the rule` \ - `# above; raising one alone silently stops the scroll).` \ - `#` \ - `# width stays 180 while label.width grew to 146: measured with a probe at` \ - `# label.width=146, the natural content width is 163pt, so the 180pt pin` \ - `# still contains it and the pill does not need to grow.` \ - width=180 \ - label.width=146 \ - label.max_chars=16 \ - scroll_texts=on \ - label.scroll_duration=100 \ - popup.horizontal=on \ - popup.background.color="$ISLAND" \ - popup.background.border_width=1 \ - popup.background.border_color="$ISLAND_BORDER" \ - popup.background.corner_radius=10 \ - update_freq=10 \ - script="$PLUGIN_DIR/music.sh" + --add item aerospace_mode center \ + --set aerospace_mode \ + drawing=off \ + icon="󰒓" \ + icon.color="$PEACH" \ + label="SERVICE" \ + label.color="$PEACH" -sketchybar \ - --add item music.prev popup.music \ - --set music.prev icon="󰒮" label.drawing=off \ - click_script="osascript -e 'tell application \"Music\" to previous track' >/dev/null 2>&1; sketchybar --set music popup.drawing=off" \ - --add item music.play popup.music \ - --set music.play icon="󰐊" label.drawing=off \ - click_script="osascript -e 'tell application \"Music\" to playpause' >/dev/null 2>&1; sketchybar --set music popup.drawing=off" \ - --add item music.next popup.music \ - --set music.next icon="󰒭" label.drawing=off \ - click_script="osascript -e 'tell application \"Music\" to next track' >/dev/null 2>&1; sketchybar --set music popup.drawing=off" # --- Right side -------------------------------------------- -# FIVE islands, grouped by category. Right items render in ADD order, so the -# first item added sits furthest right and the reading order below is the mirror -# of what you see: +# SIX islands, grouped by category. Read this half BACKWARDS: the first item +# added sits furthest right, so the file order is the mirror of the screen. # -# screen: [ system ] [ status ] [ network ] [ hardware ] [ time ] -# added: time -> hardware -> network -> status -> system +# screen: [ media ] [ system ] [ status ] [ network ] [ hardware ] [ time ] +# added: time -> hardware -> network -> status -> system -> media # -# THE ADD ORDER IS THE LAYOUT. On this side "added earlier" means "further -# right", which inverts two things people get wrong when moving an item here from -# the left (island.system was moved exactly this way): -# - a SPACER has to be added BEFORE the island it sits to the right of, not -# after it. -# - an island's MEMBERS have to be added in reverse of their screen order, so -# `cpu memory thermals` left-to-right is added thermals, memory, cpu. -# background.padding_left/right do NOT invert — they stay screen-space. Verified -# in src/bar.c: a POSITION_RIGHT item walks leftward via -# `next_position -= length + padding_right` and then `-= padding_left`. -# -# volume and wifi ride native events (volume_change, wifi_change) and never poll; -# the rest are on timers, because macOS publishes no event for any of them. -# -# EVERY ISLAND NEEDS AN ITEM THAT NEVER HIDES, with one deliberate exception. A -# bracket whose members have all hidden collapses to g_nirvana and draws nothing — -# but the spacer beside it is a separate item and stays, so the bar is left with -# an orphaned 12pt gap. The four anchors are clock, volume, wifi and pomodoro. -# -# EIGHT ITEMS SET drawing=off ON THEMSELVES, AND EVERY ONE NEEDS updates=on: -# battery brightness bluetooth calendar weather github vpn mic -# (amphetamine is NOT one of them — it only hides its LABEL, never the item, so -# when_shown is correct there. Check for item-level `drawing=off` in the plugin -# before assuming.) -# -# WHY: a self-hiding item under the config-wide when_shown default cannot -# un-hide. bar_item_update() gates timed AND event runs behind -# `updates_only_when_shown ? is_shown : true`, and bar_draw() clears the bar -# association of anything it does not draw. The item works right after a reload, -# hides itself when there is nothing to show, and then never runs again — -# silently. Seven were found in that state or heading for it and fixed here; -# volume and brightness were fixed earlier. weather is the sharpest example: it -# hides on a failed fetch, so one flaky request retired it for good. -# If you add a hideable item, this is the property you will forget. -# island.system is the exception — it EXISTS to collapse (system.sh hides all -# three members when macmon fails) and so has no anchor by design. That is safe -# only because it is the OUTERMOST island on this side: its orphaned spacer is -# then empty space at the leading edge of the run, where nothing can see it. -# Move island.system inboard and the orphan becomes a visible gap between two -# drawn islands, which is what it used to be on the left. -# Do not regroup these without checking each island keeps one. -# CAVEAT on island.hardware: volume is NOT a dependable anchor and the old note -# here — "volume.sh does hide it if the level is unreadable, which is a failure -# state rather than a normal one" — was wrong. Unreadable is the NORMAL state for -# a class-compliant USB audio interface, which has no software volume at all, so -# this island is really anchored by brightness alone whenever one is in use. -# The pad items below do NOT count as anchors — they hold each island's interior -# padding, not its reason to exist, and an island of nothing but pads would be a -# 12pt empty pill. -# -# EACH ISLAND IS BRACKETED BY AN INSET ITEM, inset..l / .r, which is what -# keeps its interior at 12pt on both ends no matter which members are hidden. -# They are added in the same mirrored order as everything else here: the .r pad -# FIRST (furthest right), the .l pad LAST. See ISLAND PADDING at the top for why -# background.padding on the end members is not good enough. -# -# Splitting is not free: each extra island costs 12pt of spacer plus 12pt of new -# interior padding. Going from one island to four cost 72pt of a 655pt budget — -# see the width budget at the top of this file. +# Two consequences that are easy to get wrong when moving an item here: +# - a SPACER is added BEFORE the island it sits to the right of, not after. +# - an island's MEMBERS are added in reverse of their screen order — the +# `cpu memory thermals` group is added thermals, memory, cpu. +# background.padding_left/right do NOT invert; they stay screen-space. # TIME ------------------------------------------------------ +# Clock, plus a calendar item that hides when the day has nothing left in it and +# shows its next event in a popup on hover. sketchybar \ --add item inset.time.r right \ --set inset.time.r "${BLANK_STYLE[@]}" width=6 @@ -579,28 +176,19 @@ sketchybar \ sketchybar \ --add item clock right \ --set clock \ - `# md-calendar_clock, not md-clock: this item shows the DATE and the` \ - `# time ("Fri 31 Jul 23:26"), so a bare clock face undersells it. It` \ - `# stays distinct from the calendar item beside it, which is plain` \ - `# md-calendar — using md-calendar here would give island.time two` \ - `# identical icons.` \ icon="󰃰" \ icon.color="$BLUE" \ - update_freq=15 \ + update_freq=1 \ script="$PLUGIN_DIR/clock.sh" -# Next event today. The bar label is the START TIME only — a title costs 60-100pt -# on this side — with the title in a popup, which costs no bar width. sketchybar \ --add item calendar right \ - `# Hover reveals the title; there is no room for it on the bar itself.` \ --subscribe calendar system_woke mouse.entered mouse.exited \ --set calendar \ popup.background.color="$ISLAND" \ popup.background.border_width=1 \ popup.background.border_color="$ISLAND_BORDER" \ popup.background.corner_radius=10 \ - `# SELF-HIDING ITEM -> updates=on. See the volume item.` \ updates=on \ update_freq=300 \ script="$PLUGIN_DIR/calendar.sh" \ @@ -613,6 +201,16 @@ sketchybar \ --set inset.time.l "${BLANK_STYLE[@]}" width=6 # HARDWARE -------------------------------------------------- +# On this machine `battery` and `volume` are hidden essentially always — a Mac +# Studio has no battery, and the class-compliant USB interface exposes no +# software volume — so `brightness` carries this island alone. It cannot vanish +# (the insets never hide), so if brightness ever failed too the result would be +# an empty 12pt pill rather than a clean disappearance. +# +# brightness polls every 5s and that is deliberate despite being the single +# largest CPU line in the bar (312 ms/min of ~1430 total). The poll is the +# PRIMARY path, not a backstop: this display is driven by MonitorControl's gamma +# dimming, which posts no brightness_change event at all. sketchybar \ --add item spacer.hardware right \ --set spacer.hardware "${BLANK_STYLE[@]}" width=12 @@ -625,37 +223,14 @@ sketchybar \ --add item battery right \ --subscribe battery power_source_change system_woke \ --set battery \ - `# SELF-HIDING ITEM -> updates=on. See the volume item. This one is easy` \ - `# to dismiss as harmless because the item is hidden while on AC — but` \ - `# that is exactly the state it is in when you unplug, and without this` \ - `# it would never notice.` \ updates=on \ update_freq=120 \ script="$PLUGIN_DIR/battery.sh" -# Brightness of whatever display is MAIN — not just the built-in, despite what -# this comment used to say. brightness.sh branches on CGDisplayIsBuiltin: a real -# backlight is read from DisplayServices, an external is read back off its GAMMA -# RAMP, which is what MonitorControl's software dimming actually manipulates. -# See the long note at the top of that script for why DDC is the wrong source. -# -# Subscribed to the native event for instant response, with a 5s poll behind it — -# the event is not guaranteed to fire for every brightness source, and both reads -# are cheap. The 5s poll is what carries MonitorControl, which emits no event at -# all: nothing tells sketchybar that a third-party app rewrote the gamma table. sketchybar \ --add item brightness right \ --subscribe brightness brightness_change system_woke \ --set brightness \ - `# updates=on, NOT the config-wide when_shown default — the same one-way` \ - `# door proved on the volume item. brightness.sh sets drawing=off when it` \ - `# cannot read a level, and bar_item_update() gates TIMED runs as well as` \ - `# event ones behind` \ - `# should_update = updates_only_when_shown ? is_shown : true` \ - `# while bar_draw() clears the bar association of anything it does not` \ - `# draw. Under when_shown the 5s poll below would stop the instant the` \ - `# item hid, so it could never read its way back — permanently and` \ - `# silently gone until a config reload.` \ updates=on \ update_freq=5 \ script="$PLUGIN_DIR/brightness.sh" @@ -664,30 +239,6 @@ sketchybar \ --add item volume right \ --subscribe volume volume_change \ --set volume \ - `# NO LONGER island.hardware's anchor. It used to be described as the one` \ - `# member that survives battery and brightness going away — but volume.sh` \ - `# hides this item whenever the output device has no software volume, and` \ - `# a class-compliant USB interface (the everyday case at this desk) is` \ - `# exactly that. With battery also hidden on AC, island.hardware is left` \ - `# carrying brightness alone. It cannot vanish — inset.hardware.l/.r never` \ - `# hide — so if brightness went too it would render as an empty 12pt pill` \ - `# rather than disappear. brightness reads a real value now (see its own` \ - `# note below), so that is a corner rather than the everyday case; if it` \ - `# ever shows up the fix is a regroup, not a fake number here.` \ - `#` \ - `# updates=on, NOT the config-wide when_shown default. This is the same` \ - `# trap thermals guards against, and it is worse here because this item` \ - `# hides itself as a matter of course rather than only on failure:` \ - `# bar_item_update() computes` \ - `# should_update = updates_only_when_shown ? is_shown : true` \ - `# and that gates EVENT-driven runs too, not just timed ones — while` \ - `# bar_draw() drops the bar association of anything it does not draw, so` \ - `# drawing=off makes is_shown false. Without this line, the moment` \ - `# volume.sh hid the item it could never run again to un-hide itself, and` \ - `# plugging in headphones would do nothing until a config reload.` \ - `# update_freq stays unset: with updates=on and update_frequency == 0 a` \ - `# timer tick still returns early (sender == NULL) while an event passes,` \ - `# so this buys back recoverability WITHOUT introducing polling.` \ updates=on \ script="$PLUGIN_DIR/volume.sh" \ click_script="osascript -e 'set volume output muted not (output muted of (get volume settings))'" @@ -697,6 +248,10 @@ sketchybar \ --set inset.hardware.l "${BLANK_STYLE[@]}" width=6 # CONNECTIVITY ---------------------------------------------- +# `wifi` is the anchor — it never hides, which is what stops this island +# collapsing when bluetooth and vpn have nothing to report. It is also the reason +# wifi is the one item here without `updates=on`: when_shown is correct for an +# item that is always shown. sketchybar \ --add item spacer.network right \ --set spacer.network "${BLANK_STYLE[@]}" width=12 @@ -709,7 +264,6 @@ sketchybar \ --add item wifi right \ --subscribe wifi wifi_change system_woke \ --set wifi \ - `# island.network's anchor — wifi never hides.` \ script="$PLUGIN_DIR/wifi.sh" \ click_script="open 'x-apple.systempreferences:com.apple.Network-Settings.extension'" @@ -717,19 +271,15 @@ sketchybar \ --add item bluetooth right \ --subscribe bluetooth system_woke \ --set bluetooth \ - `# SELF-HIDING ITEM -> updates=on. See the volume item.` \ updates=on \ update_freq=120 \ script="$PLUGIN_DIR/bluetooth.sh" \ click_script="open 'x-apple.systempreferences:com.apple.BluetoothSettings'" -# VPN. No native event exists, and waking is when the state most often changed -# while you weren't looking — hence the poll plus system_woke. sketchybar \ --add item vpn right \ --subscribe vpn system_woke \ --set vpn \ - `# SELF-HIDING ITEM -> updates=on. See the volume item.` \ updates=on \ update_freq=10 \ script="$PLUGIN_DIR/vpn.sh" \ @@ -740,6 +290,14 @@ sketchybar \ --set inset.network.l "${BLANK_STYLE[@]}" width=6 # STATUS ---------------------------------------------------- +# Ambient indicators, and the densest cluster of self-hiding items in the file — +# weather, github and mic all come and go, so this island visibly changes width +# during the day. `pomodoro` is the member that never hides and keeps it alive. +# +# EVERY ITEM HERE THAT CAN HIDE ITSELF SETS updates=on. Without it the item stops +# being polled the moment it hides and can never read its way back — it works +# after a reload, disappears once, and is gone until the next one. This is the +# trap that has been rediscovered most often in this config. sketchybar \ --add item spacer.status right \ --set spacer.status "${BLANK_STYLE[@]}" width=12 @@ -748,85 +306,29 @@ sketchybar \ --add item inset.status.r right \ --set inset.status.r "${BLANK_STYLE[@]}" width=6 -# Current conditions. 900s because weather does not change faster than that and -# every poll is an unauthenticated request to a third party that geolocates by -# source IP — see the PRIVACY note at the top of weather.sh, which also documents -# SKETCHYBAR_WEATHER_LOCATION for pinning a place or turning the item off. sketchybar \ --add item weather right \ --subscribe weather system_woke \ --set weather \ - `# SELF-HIDING ITEM -> updates=on. See the volume item. Worst case of` \ - `# the set: weather.sh hides on a FAILED FETCH, so one flaky request` \ - `# would otherwise retire this item permanently.` \ updates=on \ update_freq=900 \ script="$PLUGIN_DIR/weather.sh" -# Unread GitHub notifications. Stays hidden until `gh auth login` has been run; -# 300s is 12 requests/hour against a 5000/hour authenticated limit. sketchybar \ --add item github right \ --set github \ - `# SELF-HIDING ITEM -> updates=on. See the volume item.` \ updates=on \ update_freq=300 \ script="$PLUGIN_DIR/github.sh" \ click_script="open 'https://github.com/notifications'" -# Microphone in use — the same signal as the menu bar's orange dot. The 2s poll -# is affordable because the helper is a compiled binary; see mic.sh. sketchybar \ --add item mic right \ --set mic \ - `# SELF-HIDING ITEM -> updates=on. See the volume item for the full` \ - `# mechanism; the short version is that a hidden item under the` \ - `# config-wide when_shown default stops being polled, so mic.sh could` \ - `# never run again to notice that recording had started.` \ updates=on \ - `# 5, NOT 2. The helper costs 63ms per run (measured median of 10), so` \ - `# every second of interval here is worth ~1900ms/min of CPU. At 2 this` \ - `# item alone was 1890ms/min — 36% of the entire bar's budget and the` \ - `# most expensive thing in the config. The old value came from a comment` \ - `# in mic.swift claiming the helper ran in "single-digit ms", which was` \ - `# wrong by 8x; that comment is now corrected. Two seconds of latency on` \ - `# a recording indicator buys nothing. Lower this only after re-measuring.` \ update_freq=5 \ script="$PLUGIN_DIR/mic.sh" -# A macOS Focus-mode indicator (Do Not Disturb / Work / …) WAS BUILT HERE AND -# REMOVED. Recording it so it is not attempted a second time. -# -# The motivation was sound: the menu bar is auto-hidden, and the menu bar is the -# only place macOS shows the active Focus, so the state is invisible. The plugin -# worked — verified against synthetic fixtures for all five modes plus an unknown -# one. What killed it is that the only source of truth, -# ~/Library/DoNotDisturb/DB/Assertions.json -# is TCC-PROTECTED. Measured from a script sketchybar actually ran: -# PermissionError [Errno 1] Operation not permitted -# with the file at mode -rw-r--r-- and owned by the user — so it is macOS -# privacy, not Unix permissions. A shell in a terminal reads it fine because the -# terminal has Full Disk Access; sketchybar, launched by aerospace, does not. -# That difference is the whole trap: testing the read from a terminal proves -# nothing about whether the bar can do it. -# -# No unprotected route exists. Checked, all from a script sketchybar ran: -# ~/Library/DoNotDisturb/DB/Assertions.json BLOCKED -# defaults com.apple.donotdisturb no domain -# notificationcenterui doNotDisturb removed in Monterey -# com.apple.controlcenter readable, but its only Focus -# keys are widget position and -# visibility, not state -# ~/Library/Preferences/.GlobalPreferences readable (control: the block -# is specific, not blanket) -# -# The only fix is Full Disk Access on sketchybar, which was declined: it would -# extend FDA to every plugin script the bar spawns, and would likely need -# re-granting whenever `brew upgrade` replaces the binary. - -# Amphetamine keep-awake state. Click toggles an indefinite session; the item is -# subscribed to mouse.clicked rather than using click_script so the toggle and -# the redraw live in one script. sketchybar \ --add item amphetamine right \ --subscribe amphetamine mouse.clicked system_woke \ @@ -835,15 +337,10 @@ sketchybar \ update_freq=30 \ script="$PLUGIN_DIR/amphetamine.sh" -# Pomodoro. Click cycles idle -> 25min work -> 5min break -> idle. The script -# retunes its own update_freq — 1s while counting down, 0 (event-only) when idle, -# so the 1Hz cost is paid only while a timer is actually running. sketchybar \ --add item pomodoro right \ --subscribe pomodoro mouse.clicked system_woke \ --set pomodoro \ - `# island.status's anchor — it never hides, so the pill survives every` \ - `# other member of that island going away.` \ script="$PLUGIN_DIR/pomodoro.sh" sketchybar \ @@ -851,92 +348,109 @@ sketchybar \ --set inset.status.l "${BLANK_STYLE[@]}" width=6 # SYSTEM ---------------------------------------------------- -# CPU load, memory, temperature and fan RPM. ADDED LAST, which is what puts it -# LEFTMOST on this side — see THE ADD ORDER IS THE LAYOUT above. -# -# It used to live on the left run and was moved here; with one screen there is -# room either way, so this is placement rather than necessity. +# ONE SAMPLE, THREE ITEMS. Only `thermals` has a script; `cpu` and `memory` are +# passive — no script, no update_freq — and are written by system.sh during the +# thermals tick. So update_freq=60 sets the refresh rate for all three, and a +# macmon failure blanks all three at once. A null script on cpu/memory is by +# design, not a broken path. # -# Leftmost is the most STABLE position for it. Right-side items lay out from -# the bar's right edge leftward, so an island's width change shifts everything to -# its left and nothing to its right. This is the widest island here and the only -# one that changes width on its own (the fan RPM digit count), so putting it at -# the leading edge means an RPM change moves only its own left edge instead of -# dragging status and network with it. +# 60 and not 30: one macmon sample costs ~740ms wall because it collects a whole +# SoC telemetry frame to extract three numbers. It is only ~169 ms/min of CPU at +# this frequency since most of that is spent blocked. # -# ONE SAMPLE, THREE ITEMS: a macmon sample costs ~0.9s, so only `thermals` runs -# system.sh; it writes cpu and memory too. cpu and memory are passive — no -# script, no update_freq — and would never update on their own. +# Members are added thermals -> memory -> cpu so they READ cpu -> memory -> +# thermals. The background.padding below is screen-space and does not invert. sketchybar \ --add item spacer.system right \ --set spacer.system "${BLANK_STYLE[@]}" width=12 -# Added thermals -> memory -> cpu so they READ cpu -> memory -> thermals. The -# background.padding values below are screen-space and do NOT swap with the add -# order: cpu is still the leftmost member and still carries the left interior -# padding. sketchybar \ --add item thermals right \ --subscribe thermals system_woke \ --set thermals \ - `# Rightmost member of island.system — carries its right interior padding.` \ background.padding_right=6 \ - `# updates=on, NOT the config-wide when_shown default: system.sh hides` \ - `# all three items when macmon fails, and a hidden item under when_shown` \ - `# stops being polled — the failure would be permanent and silent.` \ updates=on \ - `# 60, NOT 30. One invocation costs ~930ms — macmon alone is 854ms` \ - `# (measured), because it samples a whole SoC telemetry frame to extract` \ - `# three numbers. At 30 that was 1860ms/min, the second-largest cost in` \ - `# the bar. CPU%, RAM and fan RPM at 60s resolution is not information` \ - `# anyone acts on faster. The real fix is to stop shelling out to macmon` \ - `# for values that host_statistics64 and host_processor_info give almost` \ - `# free, but fan RPM has no obvious no-sudo source — the HID sensor page` \ - `# exposes temperature (usage 5) and nothing fan-shaped.` \ update_freq=60 \ script="$PLUGIN_DIR/system.sh" \ click_script="open -a 'Macs Fan Control'" \ --add item memory right \ --add item cpu right \ --set cpu \ - `# background.padding, NOT a pad item — island.system is one of the two` \ - `# islands that must be able to collapse, and a pad would never hide.` \ - `# Safe here because all three members hide together, so the ends can` \ - `# never drift. See ISLAND PADDING at the top.` \ background.padding_left=6 -# --- Islands ----------------------------------------------- -# Must come after every member item exists — a bracket can only reference items -# that have already been added. -# -# A bracket renders BELOW its members, so aerospace.sh's mauve focused-workspace -# pill still draws on top of the workspace island rather than being hidden by it. -# -# Members with drawing=off are skipped when the rect is computed, so the status -# island shrinks correctly as music/thermals/battery/bluetooth/brightness come -# and go, and would vanish entirely if all of them hid at once (clock never -# does, so it always has an anchor). -# -# The inset.* members are the exception and the reason this works: they draw -# nothing but are not hidden, so they are always counted and always contribute -# their 6pt. That is what pins each island's interior at 12pt on both ends -# regardless of which real members are showing — see ISLAND PADDING at the top. +# NOW PLAYING ----------------------------------------------- +# Reads macOS's system-wide Now Playing state via media-control, so it follows +# whatever actually holds the session — IINA, Spotify, a browser tab — not just +# Apple Music. plugins/music.sh carries the why, including the private-API +# dependency this rests on and how it hides cleanly if Apple closes it. # -# THE MEMBER LIST BELOW IS A SET, NOT AN ORDER. It is the pads' ADD order — hence -# their screen position — that has to put them at the two ends; where they appear -# in the bracket line is irrelevant. Read from src/group.c rather than assumed: -# group_get_first_member/group_get_last_member scan the members for minimum and -# maximum window origin.x, and group_get_length then adds first_item's -# padding_left to last_item's padding_right. Nothing consults the list order. -# (This is also why moving island.system to the right side needed its ADD order -# reversed but left its bracket line untouched.) The lists are still written in -# screen order, because that is how they read. +# THIS BLOCK MUST STAY LAST on the right side. Added last means furthest left, +# which is what puts island.media at the inner end of the run; and the popup +# items below can only be added once `music` exists, so they travel with it. # -# corner_radius is half of height, which is what makes these pills rather than -# rounded rectangles. 32 centred in the 38pt bar puts the bottom edge at y=35 — -# see the gaps arithmetic at the top of this file. There is a hard ceiling here: -# bar_item_calculate_bounds caps an item at `bar height - (bar border_width + 1)` -# = 37, so an island taller than that is silently clipped rather than rejected. +# THE THREE WIDTHS ARE ONE SET — width 215, label.width 180, label.max_chars 20. +# SketchyBar only scrolls text truncated by max_chars, and only when label.width +# is >= the truncated text; give the box less and it silently CLIPS instead, +# which looks like a correctly-sized pill that just sits still. Measured: 20 +# chars is 164pt of ink in the 180pt box. Raise max_chars and label.width +# together or not at all. Neither value is echoed by `sketchybar --query`. +sketchybar \ + --add item spacer.media right \ + --set spacer.media "${BLANK_STYLE[@]}" width=12 + +sketchybar \ + --add item music right \ + --subscribe music mouse.clicked \ + --set music \ + updates=on \ + background.padding_left=6 \ + background.padding_right=6 \ + width=215 \ + label.width=180 \ + label.max_chars=20 \ + scroll_texts=on \ + label.scroll_duration=100 \ + popup.horizontal=on \ + popup.background.color="$ISLAND" \ + popup.background.border_width=1 \ + popup.background.border_color="$ISLAND_BORDER" \ + popup.background.corner_radius=10 \ + update_freq=10 \ + script="$PLUGIN_DIR/music.sh" + +# Transport goes through media-control, not `tell application "Music"`, for the +# same reason music.sh does: the commands are addressed to whichever app owns the +# system Now Playing session, so these work for IINA and Spotify and a browser +# tab, none of which the old AppleScript could reach. Command names are exact — +# `media-control help` lists them with their MediaRemote command IDs. +sketchybar \ + --add item music.prev popup.music \ + --set music.prev icon="󰒮" label.drawing=off \ + click_script="media-control previous-track >/dev/null 2>&1; sketchybar --set music popup.drawing=off" \ + --add item music.play popup.music \ + --set music.play icon="󰐊" label.drawing=off \ + click_script="media-control toggle-play-pause >/dev/null 2>&1; sketchybar --set music popup.drawing=off" \ + --add item music.next popup.music \ + --set music.next icon="󰒭" label.drawing=off \ + click_script="media-control next-track >/dev/null 2>&1; sketchybar --set music popup.drawing=off" + +# --- Islands ----------------------------------------------- +# The frosted pills. These brackets are the ONLY backgrounds drawn on the whole +# bar, and blur_radius frosts each one individually rather than the entire strip. +# Must come after every member item exists — a bracket naming a missing item only +# warns, it does not fail, so a stale line here is invisible. +# +# THREE OF THEM CAN VANISH: island.system, island.media, and island.mode. For the +# first two, every member hides at once (system.sh when macmon fails, music.sh when +# nothing is playing), and a bracket whose members are all hidden draws nothing at +# all. That is why those two use background.padding on their end members instead of +# inset pad items — a pad never hides, and would strand an empty pill exactly where +# the island is supposed to disappear. island.mode is the deliberate case: its one +# member sits drawing=off except during service mode, so the island is invisible by +# default and appears only while AeroSpace has toggled the pill on. +# +# island.app also uses background.padding but does NOT collapse: front_app always +# draws. The remaining four are pinned open by their inset.* members. ISLAND_STYLE=( background.drawing=on background.height=32 @@ -944,23 +458,6 @@ ISLAND_STYLE=( background.color="$ISLAND" background.border_width=1 background.border_color="$ISLAND_BORDER" - # FROSTED PILLS. Note this is `blur_radius`, an ITEM property — NOT - # `background.blur_radius`, which does not exist. It works here because a - # bracket is itself an item and its window is the island's rect, so the blur - # lands on exactly the pill and the transparent bar behind it stays sharp. - # This is the alternative to a bar-level blur, which would frost the whole - # rectangle including the gaps between islands — tried, rejected. - # - # THE THING TO WATCH: the blur is applied to the item's WINDOW, which is - # rectangular, while the pill drawn in it is rounded. Whether the corners - # show a square frosted patch depends on macOS masking the backdrop by the - # window's alpha. Look at the corners of an island against a high-contrast - # wallpaper before assuming this is clean; blur_radius=0 reverts it with - # nothing else to change. - # - # Private API: reaches SLSSetWindowBackgroundBlurRadius via - # window_set_blur_radius (src/window.c). Undocumented SkyLight, so a - # plausible casualty of a macOS update — the failure mode is cosmetic. blur_radius=30 ) @@ -968,30 +465,18 @@ sketchybar \ --add bracket island.spaces inset.spaces.l "${space_items[@]}" inset.spaces.r \ --set island.spaces "${ISLAND_STYLE[@]}" \ \ - `# Sole member, so its interior comes from background.padding on front_app` \ - `# rather than pad items — safe because this island cannot collapse. See the` \ - `# front_app block above. If it is ever disabled again, comment THIS OUT TOO:` \ - `# a bracket naming an item that does not exist only WARNS, it does not fail,` \ - `# so a stale line here costs nothing visible and hides the mistake.` \ --add bracket island.app front_app \ --set island.app "${ISLAND_STYLE[@]}" \ \ - `# THE TWO COLLAPSING ISLANDS. No pads here, deliberately: every member of` \ - `# each hides at once — system.sh when macmon fails, music.sh when nothing` \ - `# is playing — and the bracket then goes to g_nirvana and draws nothing at` \ - `# all. A pad never hides, so adding one would strand an empty 12pt pill` \ - `# exactly where the island is supposed to disappear. Their interiors come` \ - `# from background.padding on the end members instead, which is safe only` \ - `# because their members hide as a unit and the ends cannot drift.` \ + --add bracket island.mode aerospace_mode \ + --set island.mode "${ISLAND_STYLE[@]}" \ + \ --add bracket island.system cpu memory thermals \ --set island.system "${ISLAND_STYLE[@]}" \ \ --add bracket island.media music \ --set island.media "${ISLAND_STYLE[@]}" \ \ - `# The right side, grouped by category. Each of these four has exactly one` \ - `# real member that never hides — clock, volume, wifi, pomodoro — so none` \ - `# can collapse and strand its spacer. See the note above the right side.` \ --add bracket island.time inset.time.r clock calendar inset.time.l \ --set island.time "${ISLAND_STYLE[@]}" \ \ @@ -1005,4 +490,6 @@ sketchybar \ --set island.status "${ISLAND_STYLE[@]}" # --- Go ---------------------------------------------------- +# Flush the batched config: one forced update pass so every item runs its script +# and paints now, instead of waiting for its first event or tick. sketchybar --update