From b4f4bd9a065e157bc962540d516b4639f881d47f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:42:42 +0000 Subject: [PATCH 01/24] docs(02): refresh the bug-fix plans for holzBar Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .planning/ROADMAP.md | 4 +- .planning/phases/02-bug-fixes/02-01-PLAN.md | 134 ++++++++++---------- .planning/phases/02-bug-fixes/02-02-PLAN.md | 119 ++++++++--------- .planning/phases/02-bug-fixes/02-03-PLAN.md | 68 +++++----- .planning/phases/02-bug-fixes/02-04-PLAN.md | 69 +++++----- 5 files changed, 204 insertions(+), 190 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 9ad4afaf..0b5752d2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -82,9 +82,9 @@ Plans: **Plans**: 4 plans (sequential waves: one PR branch, every task verified by its CI checks) Plans: -- [ ] 02-01-PLAN.md — Tracer: tested `Ice/Core` package target; spacing relaunch keeps going past skipped processes (MenuBarAgent skipped on macOS 27), 10 s quit wait, no force-termination; phase PR opened +- [ ] 02-01-PLAN.md — Tracer: tested `holzBar/Core` package target (`HolzBarCore`); spacing relaunch keeps going past skipped processes (MenuBarAgent skipped on macOS 27), 10 s event-driven quit wait, no force-termination; phase PR opened - [ ] 02-02-PLAN.md — Hotkey recorder refuses Option-only combinations on macOS 15+ and says why (signature unchanged); every permission wait returns -- [ ] 02-03-PLAN.md — XPC service accepts holzIce's ad hoc build by pinning the embedding app's signing identifier and code directory hashes (proven by a CodeSignature test suite); foreign processes still rejected +- [ ] 02-03-PLAN.md — XPC service accepts holzBar's ad hoc build by pinning the embedding app's signing identifier and code directory hashes (proven by a CodeSignature test suite); foreign processes still rejected - [ ] 02-04-PLAN.md — Lock-guarded event source cache; macOS 27 system item allowlist 0 to 127 with matching comment and tests; PR body complete ### Phase 3: Ice and Sparkle leftovers diff --git a/.planning/phases/02-bug-fixes/02-01-PLAN.md b/.planning/phases/02-bug-fixes/02-01-PLAN.md index a01fc4f8..68f3ac4a 100644 --- a/.planning/phases/02-bug-fixes/02-01-PLAN.md +++ b/.planning/phases/02-bug-fixes/02-01-PLAN.md @@ -6,9 +6,9 @@ wave: 1 depends_on: [] files_modified: - Package.swift - - Ice/Core/SpacingRelaunch.swift - - Tests/IceCoreTests/SpacingRelaunchTests.swift - - Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift + - holzBar/Core/SpacingRelaunch.swift + - Tests/HolzBarCoreTests/SpacingRelaunchTests.swift + - holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift autonomous: true requirements: [BUG-01, BUG-02, BUG-03] @@ -20,45 +20,45 @@ estimate: must_haves: truths: - - "Applying a spacing offset relaunches every process that owns a menu bar item except holzIce itself, Control Center (told to quit once, at the end) and MenuBarAgent; a skipped owner never stops the others from being relaunched" + - "Applying a spacing offset relaunches every process that owns a menu bar item except holzBar itself, Control Center (told to quit once, at the end) and MenuBarAgent; a skipped owner never stops the others from being relaunched" - "On macOS 27 the owning processes come from MenuBarItemProvider27.items(), the Accessibility read that names the owner of every item, concealed ones included" - - "An app that has not quit 10 seconds after being asked is left running and named in the error alert; holzIce never force-terminates it" - - "Waiting for an app to quit always returns: when it quits, at the timeout, or when the task is cancelled" - - "swift test runs the new IceCoreTests target in CI, and build, test and swiftlint pass on the head of the open phase PR" + - "An app that has not quit 10 seconds after being asked is left running and named in the error alert; holzBar never force-terminates it" + - "Waiting for an app to quit always returns: when it quits, at the timeout, or when the task is cancelled; it reacts to the app's termination through key-value observation of isTerminated and never polls" + - "swift test runs the new HolzBarCoreTests target in CI, and build, test and swiftlint pass on the head of the open phase PR" artifacts: - - path: Ice/Core/SpacingRelaunch.swift + - path: holzBar/Core/SpacingRelaunch.swift provides: "Pure spacing-relaunch decisions: which processes to relaunch, the quit timeout, a wait that always returns" contains: "enum SpacingRelaunch" - - path: Tests/IceCoreTests/SpacingRelaunchTests.swift + - path: Tests/HolzBarCoreTests/SpacingRelaunchTests.swift provides: "Swift Testing suite for SpacingRelaunch" contains: "@Suite(\"SpacingRelaunch\")" - path: Package.swift - provides: "IceCore library target (Ice/Core) and IceCoreTests test target next to the existing macOS 27 targets" - contains: "path: \"Ice/Core\"" - - path: Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift + provides: "HolzBarCore library target (holzBar/Core) and HolzBarCoreTests test target next to the existing macOS 27 targets" + contains: "path: \"holzBar/Core\"" + - path: holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift provides: "Spacing relaunch wired to SpacingRelaunch" contains: "SpacingRelaunch.processesToRelaunch" key_links: - - from: Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift - to: Ice/Core/SpacingRelaunch.swift + - from: holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift + to: holzBar/Core/SpacingRelaunch.swift via: "applyOffset() asks SpacingRelaunch which processes to relaunch and waits through SpacingRelaunch.waitUntil" pattern: "SpacingRelaunch\\.(processesToRelaunch|waitUntil)" - - from: Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift - to: Ice/MenuBar/MacOS27/MenuBarItemProvider27.swift + - from: holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift + to: holzBar/MenuBar/MacOS27/MenuBarItemProvider27.swift via: "macOS 27 branch reads the item owners through Accessibility" pattern: "MenuBarItemProvider27\\.items\\(\\)" - from: Package.swift - to: Ice/Core/SpacingRelaunch.swift - via: "IceCore target compiles Ice/Core for swift test; the app compiles the same file through the synchronized Ice folder group" - pattern: "path: \"Ice/Core\"" + to: holzBar/Core/SpacingRelaunch.swift + via: "HolzBarCore target compiles holzBar/Core for swift test; the app compiles the same file through the synchronized holzBar folder group" + pattern: "path: \"holzBar/Core\"" --- Fix the menu bar item spacing relaunch (BUG-01, BUG-02, BUG-03) and open the phase PR. -Purpose: applying "Menu bar item spacing" is supposed to quit and reopen every app with a menu bar item. Today the loop leaves at the first skipped process (Control Center or holzIce itself), so which apps get relaunched depends on the iteration order of a Set (BUG-01). Apps that take longer than 1 s to quit are killed, and the wait never resumes if the manager is gone or the app refuses to quit (BUG-02). On macOS 27 the owner list must come from the Accessibility read, and MenuBarAgent, which hosts the system items there, must never be quit like an app (BUG-03). -This plan also lays the path every later plan of the phase verifies through: a new pure, unit-tested `Ice/Core` folder compiled both into the app and into the test-only package (D-04), and the one draft PR for the whole phase (D-05). -Output: `Ice/Core/SpacingRelaunch.swift` and its tests, the `IceCore`/`IceCoreTests` targets in `Package.swift`, the reworked `MenuBarItemSpacingManager.swift`, the draft phase PR. +Purpose: applying "Menu bar item spacing" is supposed to quit and reopen every app with a menu bar item. Today the loop leaves at the first skipped process (Control Center or holzBar itself), so which apps get relaunched depends on the iteration order of a Set (BUG-01). Apps that take longer than 1 s to quit are killed, and the wait never resumes if the manager is gone or the app refuses to quit (BUG-02). On macOS 27 the owner list must come from the Accessibility read, and MenuBarAgent, which hosts the system items there, must never be quit like an app (BUG-03). +This plan also lays the path every later plan of the phase verifies through: a new pure, unit-tested `holzBar/Core` folder compiled both into the app and into the test-only package (D-04), and the one draft PR for the whole phase (D-05). +Output: `holzBar/Core/SpacingRelaunch.swift` and its tests, the `HolzBarCore`/`HolzBarCoreTests` targets in `Package.swift`, the reworked `MenuBarItemSpacingManager.swift`, the draft phase PR. @@ -75,55 +75,58 @@ Output: `Ice/Core/SpacingRelaunch.swift` and its tests, the `IceCore`/`IceCoreTe @.planning/codebase/CONVENTIONS.md @.planning/codebase/TESTING.md @Package.swift -@Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift +@holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift @.swiftlint.yml User decisions (orchestrator, 2026-10-02). Cite them by ID: - D-01: BUG-05 changes only the recorder's validation and feedback. The Carbon hotkey signature `OSType(1231250720)` stays identical to Ice's (both apps never run in parallel). -- D-02: BUG-06 keeps rejecting foreign processes. The XPC listener must accept holzIce on ad hoc builds (no team identifier). Choose the safest option that works with ad hoc signing. +- D-02: BUG-06 keeps rejecting foreign processes. The XPC listener must accept holzBar on ad hoc builds (no team identifier). Choose the safest option that works with ad hoc signing. - D-03: BUG-08 takes the allowlist range from the evidence in code, comments and docs, and makes comment and code agree. - D-04: Where logic is pure and testable, add Swift Testing tests to the test-only Package.swift (only code that compiles there without AppKit-heavy dependencies). Never disable, skip or delete a test. - D-05: One PR for the whole phase, from `claude/ice-fork-development-hzdl1d` to `main`. Executors work in isolated worktrees and push with `git push origin HEAD:claude/ice-fork-development-hzdl1d` (never force). The orchestrator marks the PR ready and merges it. Everything in the repository is English. Facts gathered while planning (2026-10-02). Do not re-derive them: -- There is no Mac and no Swift compiler here. GitHub Actions is the only build: `build` (xcodebuild, macos-26, Xcode 26.6), `test` (`swift test`, macos-26) and `swiftlint` (SwiftLint 0.65.1 `--strict`, only on pushes that touch `*.swift`). On the Phase 1 head, `test` reported "Test run with 114 tests in 22 suites passed". -- `gh` works for `holzcloud/holzIce` through the REST API only (GraphQL is blocked, so no `gh pr create`/`gh pr view`). Check-run IDs double as job IDs: `gh api repos/holzcloud/holzIce/actions/jobs//logs` returns the raw job log. -- Log formats (read from Phase 1 runs): Swift Testing prints `◇ Suite "Name" started.`, `✔ Test "Display name" passed after 0.008 seconds.` and a final `Test run with N tests in M suites passed`. The build job prints `==> N compiler warnings`, then one line per warning such as `Ice/Events/HIDEventManager.swift:575:46: warning: ...`, then `** BUILD SUCCEEDED **`. MenuBarItemSpacingManager.swift has no warning today. +- There is no Mac and no Swift compiler here. GitHub Actions is the only build: `build` (xcodebuild of `holzBar.xcodeproj`, scheme `holzBar`, macos-26, Xcode 26.6, followed by the step "Check the identifiers", which prints `==> Identifiers` and fails when the built XPC service identifier differs from `MenuBarItemService.name` or from the app's identifier plus `.MenuBarItemService`), `test` (`swift test`, macos-26) and `swiftlint` (SwiftLint 0.65.1 `--strict`, only on pushes that touch `*.swift`). A fourth workflow, `cask` (`.github/workflows/cask.yml`), runs only on pull requests that touch `Casks/**`, `cask_renames.json` or `cask.yml`; Phase 2 touches none of them, so it does not run and the readback below does not wait for it. On main after the rename (16539c1), `test` reported "Test run with 114 tests in 22 suites passed". +- The GitHub repository is now `holzcloud/holzBar` (renamed after Phase 01.1). Through this session's proxy, `gh api repos/holzcloud/holzIce/...` fails (GitHub redirects the old name to a numeric `repositories/` path, which the proxy refuses), so every `gh api` call uses `repos/holzcloud/holzBar`. The `origin` remote may still say `holzcloud/holzIce`; git follows GitHub's redirect (`git ls-remote origin` works). If a push fails because of the old name, run `git remote set-url origin https://github.com/holzcloud/holzBar` and push again. +- `gh` works for `holzcloud/holzBar` through the REST API only (GraphQL is blocked, so no `gh pr create`/`gh pr view`). Check-run IDs double as job IDs: `gh api repos/holzcloud/holzBar/actions/jobs//logs` returns the raw job log. +- Log formats (read from the CI runs, last on main 16539c1): Swift Testing prints `◇ Suite "Name" started.`, `✔ Test "Display name" passed after 0.008 seconds.` and a final `Test run with N tests in M suites passed`. The build job prints `==> N compiler warnings`, then one line per warning such as `holzBar/Events/HIDEventManager.swift:575:46: warning: ...`, then `** BUILD SUCCEEDED **`. MenuBarItemSpacingManager.swift has no warning today. - Waiting on CI: foreground `sleep` is blocked. Poll with a background until-loop or the Monitor tool every 30 s, for at most 30 minutes, until the `build`, `test` and `swiftlint` check runs of the PR head all have `status == "completed"`. If one fails, read its job log, fix, commit, push, wait again. -- Phase 1's PR #30 is merged; `claude/ice-fork-development-hzdl1d` and `main` both point at 2b1b4f8. No PR is open from the branch. -- The Xcode project uses filesystem-synchronized groups: every `.swift` file under `Ice/` (including a new `Ice/Core/` folder) is compiled into the app target automatically, and every file under `Shared/` into both the app and the XPC service. Two Swift files with the same base name in one target do not compile. -- `MenuBarItem.getMenuBarItems(option:)` already routes to `MenuBarItemProvider27.items()` on macOS 27 (`Ice/MenuBar/MenuBarItems/MenuBarItem.swift:271-283`). There, `ownerPID == sourcePID` is the process that published the item under `AXExtrasMenuBar`, concealed items included, and `MenuBarItemProvider27.menuBarAgentBundleID` (`com.apple.MenuBarAgent`) hosts the system items (`docs/macos27.md`: macOS 27 draws all items through MenuBarAgent). +- Phase 01.1's PR #33 (the rename to holzBar) is merged; `claude/ice-fork-development-hzdl1d` and `main` both point at its merge commit 16539c1. No PR is open from the branch. +- Names after the rename (Phase 01.1): source folder `holzBar/`, project `holzBar.xcodeproj`, target, scheme and Swift module `holzBar`, test package `HolzBarMacOS27Core` (tests in `Tests/HolzBarMacOS27CoreTests`), bundle identifiers `com.holzcloud.holzBar` and `com.holzcloud.holzBar.MenuBarItemService`. `.planning/codebase/*.md` were written before the rename: read their `Ice/` as `holzBar/`, `IceMacOS27Core` as `HolzBarMacOS27Core` and `Ice.xcodeproj` as `holzBar.xcodeproj`. +- CLAUDE.md "Principles" bind every change: modern (Swift concurrency, current APIs; replace outdated ones in the code you touch), lean (no polling when an event or notification exists), private (no network, no personal data in logs), least privilege (no new permission or entitlement). This plan follows them: the quit wait observes the app's termination instead of polling, and the touched spacing code drops Combine. The `defaults` writes stay as they are: replacing them with `CFPreferences` is API-04, a requirement of its own in Phase 4, and this plan does not change those lines. +- The Xcode project uses filesystem-synchronized groups: every `.swift` file under `holzBar/` (including a new `holzBar/Core/` folder) is compiled into the app target automatically, and every file under `Shared/` into both the app and the XPC service. Two Swift files with the same base name in one target do not compile. +- `MenuBarItem.getMenuBarItems(option:)` already routes to `MenuBarItemProvider27.items()` on macOS 27 (`holzBar/MenuBar/MenuBarItems/MenuBarItem.swift:271-283`). There, `ownerPID == sourcePID` is the process that published the item under `AXExtrasMenuBar`, concealed items included, and `MenuBarItemProvider27.menuBarAgentBundleID` (`com.apple.MenuBarAgent`) hosts the system items (`docs/macos27.md`: macOS 27 draws all items through MenuBarAgent). - Out of scope here: the `defaults` process and `runCommand` (Phase 4, API-04), the settings pane's text (unchanged), Combine elsewhere. -- SwiftLint lints `Ice/` only, so `Ice/Core/*.swift` must pass it: header `//`, `// .swift`, `// Ice`, `//`; mandatory trailing commas in multi-line collections; static-only types are caseless `enum`s (`convenience_type`); no force unwrapping; 4-space indentation; a `///` doc comment on every type and member. +- SwiftLint lints `holzBar/` only, so `holzBar/Core/*.swift` must pass it: header `//`, `// .swift`, `// holzBar`, `//`; mandatory trailing commas in multi-line collections; static-only types are caseless `enum`s (`convenience_type`); no force unwrapping; 4-space indentation; a `///` doc comment on every type and member. CI readback used by every verify below (`$D` holds the three job logs afterwards): -`git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log"` +`git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log"` Task 1 (tracer): a skipped process no longer stops the spacing relaunch, end to end through a new tested Core target and the phase PR - gh api repos/holzcloud/holzIce --jq .permissions.push prints true, and after git fetch origin, origin/claude/ice-fork-development-hzdl1d is an ancestor of HEAD - Package.swift, Ice/Core/SpacingRelaunch.swift (new), Tests/IceCoreTests/SpacingRelaunchTests.swift (new), Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift + gh api repos/holzcloud/holzBar --jq .permissions.push prints true, and after git fetch origin, origin/claude/ice-fork-development-hzdl1d is an ancestor of HEAD + Package.swift, holzBar/Core/SpacingRelaunch.swift (new), Tests/HolzBarCoreTests/SpacingRelaunchTests.swift (new), holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift - - Owners [Control Center pid 10, app pid 20, holzIce's own pid 30, app pid 40] give [20, 40]: the skipped owner with the lowest pid does not stop the loop (the BUG-01 regression) - - holzIce's own pid is never returned, whatever its bundle identifier + - Owners [Control Center pid 10, app pid 20, holzBar's own pid 30, app pid 40] give [20, 40]: the skipped owner with the lowest pid does not stop the loop (the BUG-01 regression) + - holzBar's own pid is never returned, whatever its bundle identifier - Owners whose bundle identifier is com.apple.controlcenter or com.apple.MenuBarAgent are never returned - The same pid listed several times is returned once; the result is sorted by pid - An owner without a bundle identifier is still returned (the manager reports it as not restarted, as today) -This slice wires one path through every layer the phase touches: a pure file in a new `Ice/Core` folder, the SwiftPM test package, the app code that uses it, the macOS CI jobs, and the check runs read back from the phase PR. Later tasks and plans add to this path; they do not rebuild it. +This slice wires one path through every layer the phase touches: a pure file in a new `holzBar/Core` folder, the SwiftPM test package, the app code that uses it, the macOS CI jobs, and the check runs read back from the phase PR. Later tasks and plans add to this path; they do not rebuild it. 1. Start from the pushed branch head: `git fetch origin`, then rebase onto `origin/claude/ice-fork-development-hzdl1d` if HEAD does not contain it. -2. `Package.swift` (D-04): keep both existing targets exactly as they are. Add a library target `IceCore` with `path: "Ice/Core"` and a test target `IceCoreTests` (dependency `IceCore`, `path: "Tests/IceCoreTests"`), both with `swiftSettings: [.swiftLanguageMode(.v5)]` like the existing ones, with trailing commas. Keep the package name. Rewrite the header comment so it says the test-only package compiles Ice's pure logic, `Ice/Core` (any macOS) and `Ice/MenuBar/MacOS27/Core` (macOS 27), so it can be unit tested with `swift test`, and that the app compiles the same files through the synchronized `Ice` folder group. -3. Create `Ice/Core/SpacingRelaunch.swift` (SwiftLint header, `import Foundation` only, no AppKit, no app types). A caseless `enum SpacingRelaunch`, documented as the pure decisions of the menu bar item spacing relaunch, with: +2. `Package.swift` (D-04): keep both existing targets exactly as they are. Add a library target `HolzBarCore` with `path: "holzBar/Core"` and a test target `HolzBarCoreTests` (dependency `HolzBarCore`, `path: "Tests/HolzBarCoreTests"`), both with `swiftSettings: [.swiftLanguageMode(.v5)]` like the existing ones, with trailing commas. Keep the package name `HolzBarMacOS27Core` (CI runs `swift test` on it). Rewrite the header comment so it says the test-only package compiles holzBar's pure logic, `holzBar/Core` (any macOS) and `holzBar/MenuBar/MacOS27/Core` (macOS 27), so it can be unit tested with `swift test`, and that the app compiles the same files through the synchronized `holzBar` folder group. +3. Create `holzBar/Core/SpacingRelaunch.swift` (SwiftLint header, `import Foundation` only, no AppKit, no app types). A caseless `enum SpacingRelaunch`, documented as the pure decisions of the menu bar item spacing relaunch, with: - a nested `struct Owner: Hashable` with `let pid: pid_t` and `let bundleIdentifier: String?` (a process that owns at least one menu bar item); - `static let controlCenterBundleIdentifier = "com.apple.controlcenter"`, documented: Control Center relaunches itself once told to quit, so the manager asks it once, after the other apps; - - `static let menuBarAgentBundleIdentifier = "com.apple.MenuBarAgent"`, documented: on macOS 27 MenuBarAgent hosts the system items (the same identifier as `MenuBarItemProvider27.menuBarAgentBundleID`); it is a system agent, never quit and reopened by holzIce; + - `static let menuBarAgentBundleIdentifier = "com.apple.MenuBarAgent"`, documented: on macOS 27 MenuBarAgent hosts the system items (the same identifier as `MenuBarItemProvider27.menuBarAgentBundleID`); it is a system agent, never quit and reopened by holzBar; - `static func processesToRelaunch(owners: [Owner], ownPID: pid_t) -> [pid_t]`: every distinct pid of `owners` except `ownPID` and the two identifiers above, sorted ascending. The doc comment says a skipped owner never stops the others from being relaunched. -4. Create `Tests/IceCoreTests/SpacingRelaunchTests.swift`: `import Foundation`, `import Testing`, `@testable import IceCore`, `@Suite("SpacingRelaunch") struct SpacingRelaunchTests` with one `@Test` per behavior above. Name the first one exactly `@Test("A skipped owner does not stop the others")`; give the others short sentence names. Use inline literals as fixtures (TESTING.md). +4. Create `Tests/HolzBarCoreTests/SpacingRelaunchTests.swift`: `import Foundation`, `import Testing`, `@testable import HolzBarCore`, `@Suite("SpacingRelaunch") struct SpacingRelaunchTests` with one `@Test` per behavior above. Name the first one exactly `@Test("A skipped owner does not stop the others")`; give the others short sentence names. Use inline literals as fixtures (TESTING.md). 5. `MenuBarItemSpacingManager.applyOffset()` (BUG-01, BUG-03). Keep the `defaults` writes and both 100 ms sleeps as they are. - Collect the items: inside `if #available(macOS 27.0, *)` call `MenuBarItemProvider27.items()`, with a comment that macOS 27 has no item windows and Accessibility names the owning process of every item, concealed ones included; otherwise keep `MenuBarItem.getMenuBarItems(option: .activeSpace)`. - Build the owners: the distinct `sourcePID ?? ownerPID` of the items, each paired with `NSRunningApplication(processIdentifier:)?.bundleIdentifier`. Pass them with `ownPID: ProcessInfo.processInfo.processIdentifier` to `SpacingRelaunch.processesToRelaunch`. @@ -131,46 +134,45 @@ This slice wires one path through every layer the phase touches: a pure file in - Collect failures without mutating a captured array from child tasks: `withTaskGroup(of: String?.self)`, each child (`@MainActor`, as today) returns the app's name when the relaunch failed (keep the Spotlight special case exactly as it is) and nil otherwise; the parent appends every non-nil result to `failedApps`. - Leave the Control Center tail and the quitting/waiting code unchanged; Task 2 replaces them. 6. Commit (for example `fix(02-01): relaunch every app when applying spacing`), then `git push origin HEAD:claude/ice-fork-development-hzdl1d`. -7. Open the phase PR if none is open (check with `gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].number'`). Create it as a draft through REST: `gh api -X POST repos/holzcloud/holzIce/pulls` with `-f title="Bug fixes: spacing relaunch, hotkeys, XPC on ad hoc builds, permission wait, data race, allowlist"`, `-f head=claude/ice-fork-development-hzdl1d`, `-f base=main`, `-F draft=true` and `-f body=...`. The body, in English: one paragraph saying this draft collects all of Phase 2 (BUG-01 to BUG-08) and is marked ready once every check is green; a bullet list of what has landed so far; then the PR attribution lines from your session's system reminder (D-05). +7. Open the phase PR if none is open (check with `gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].number'`). Create it as a draft through REST: `gh api -X POST repos/holzcloud/holzBar/pulls` with `-f title="Bug fixes: spacing relaunch, hotkeys, XPC on ad hoc builds, permission wait, data race, allowlist"`, `-f head=claude/ice-fork-development-hzdl1d`, `-f base=main`, `-F draft=true` and `-f body=...`. The body, in English: one paragraph saying this draft collects all of Phase 2 (BUG-01 to BUG-08) and is marked ready once every check is green; a bullet list of what has landed so far; then the PR attribution lines from your session's system reminder (D-05). 8. Wait for CI as described in the context. If `build` fails, read its log (the summary after the warning list shows the `error:` lines) and fix. If `test` fails, fix the code or a wrong expectation; never disable, skip or delete a test (D-04). Fix any warning the build log shows in a file this task created or changed. Push and wait until all three checks are green on the PR head. - grep -q 'name: "IceCore"' Package.swift && grep -q 'path: "Ice/Core"' Package.swift && grep -q 'name: "IceCoreTests"' Package.swift && grep -q 'path: "Tests/IceCoreTests"' Package.swift && grep -q 'path: "Ice/MenuBar/MacOS27/Core"' Package.swift && sed -n 2p Ice/Core/SpacingRelaunch.swift | grep -qx '// SpacingRelaunch.swift' && sed -n 3p Ice/Core/SpacingRelaunch.swift | grep -qx '// Ice' && grep -q 'enum SpacingRelaunch' Ice/Core/SpacingRelaunch.swift && grep -q 'static func processesToRelaunch' Ice/Core/SpacingRelaunch.swift && ! grep -nE '^import (AppKit|Cocoa|SwiftUI)' Ice/Core/SpacingRelaunch.swift && test "$(grep -c '@Test' Tests/IceCoreTests/SpacingRelaunchTests.swift)" -ge 5 && grep -q 'SpacingRelaunch.processesToRelaunch' Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift && grep -q 'MenuBarItemProvider27.items()' Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && N=$(grep -oE 'Test run with [0-9]+ tests' "$D/test.log" | tail -1 | grep -oE '[0-9]+') && test "$N" -gt 114 && grep -q 'Suite "SpacingRelaunch" passed' "$D/test.log" && grep -q 'Test "A skipped owner does not stop the others" passed' "$D/test.log" && ! grep -E '(Ice/Core/[A-Za-z0-9]+|Ice/MenuBar/Spacing/MenuBarItemSpacingManager)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "tracer green on $H ($N tests)" - On the Mac (macOS 26.7.1), with several apps that have menu bar items running: holzIce Settings, General, change "Menu bar item spacing" and press Apply. Every app with a menu bar item quits and reopens, including when Control Center or holzIce would have come first; none is left out at random. Repeat once to see that the set of relaunched apps is the same. + grep -q 'name: "HolzBarCore"' Package.swift && grep -q 'path: "holzBar/Core"' Package.swift && grep -q 'name: "HolzBarCoreTests"' Package.swift && grep -q 'path: "Tests/HolzBarCoreTests"' Package.swift && grep -q 'path: "holzBar/MenuBar/MacOS27/Core"' Package.swift && sed -n 2p holzBar/Core/SpacingRelaunch.swift | grep -qx '// SpacingRelaunch.swift' && sed -n 3p holzBar/Core/SpacingRelaunch.swift | grep -qx '// holzBar' && grep -q 'enum SpacingRelaunch' holzBar/Core/SpacingRelaunch.swift && grep -q 'static func processesToRelaunch' holzBar/Core/SpacingRelaunch.swift && ! grep -nE '^import (AppKit|Cocoa|SwiftUI)' holzBar/Core/SpacingRelaunch.swift && test "$(grep -c '@Test' Tests/HolzBarCoreTests/SpacingRelaunchTests.swift)" -ge 5 && grep -q 'SpacingRelaunch.processesToRelaunch' holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift && grep -q 'MenuBarItemProvider27.items()' holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && N=$(grep -oE 'Test run with [0-9]+ tests' "$D/test.log" | tail -1 | grep -oE '[0-9]+') && test "$N" -gt 114 && grep -q 'Suite "SpacingRelaunch" passed' "$D/test.log" && grep -q 'Test "A skipped owner does not stop the others" passed' "$D/test.log" && ! grep -E '(holzBar/Core/[A-Za-z0-9]+|holzBar/MenuBar/Spacing/MenuBarItemSpacingManager)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "tracer green on $H ($N tests)" + On the Mac (macOS 26.7.1), with several apps that have menu bar items running: holzBar Settings, General, change "Menu bar item spacing" and press Apply. Every app with a menu bar item quits and reopens, including when Control Center or holzBar would have come first; none is left out at random. Repeat once to see that the set of relaunched apps is the same. - An open draft PR from claude/ice-fork-development-hzdl1d to main exists. On its head, build, test and swiftlint succeed; swift test reports more than 114 tests and the "SpacingRelaunch" suite passed, including "A skipped owner does not stop the others"; the build log shows no warning in Ice/Core or MenuBarItemSpacingManager.swift. applyOffset() relaunches every owner SpacingRelaunch returns and reads the owners through MenuBarItemProvider27 on macOS 27. + An open draft PR from claude/ice-fork-development-hzdl1d to main exists. On its head, build, test and swiftlint succeed; swift test reports more than 114 tests and the "SpacingRelaunch" suite passed, including "A skipped owner does not stop the others"; the build log shows no warning in holzBar/Core or MenuBarItemSpacingManager.swift. applyOffset() relaunches every owner SpacingRelaunch returns and reads the owners through MenuBarItemProvider27 on macOS 27. Task 2: an app gets 10 seconds to quit, is never killed, and the wait always returns - Ice/Core/SpacingRelaunch.swift, Tests/IceCoreTests/SpacingRelaunchTests.swift, Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift + holzBar/Core/SpacingRelaunch.swift, Tests/HolzBarCoreTests/SpacingRelaunchTests.swift, holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift - - waitUntil returns true without sleeping when the condition already holds (works with a timeout of zero) - - waitUntil returns true as soon as the condition starts to hold: a condition that turns true on its third check returns true after exactly three checks - - waitUntil returns false once the timeout has passed without the condition holding (timeout 50 ms, poll 5 ms) - - A cancelled wait returns at once with the condition's current value instead of polling until the timeout (timeout 30 s, measured under 5 s) + - waitUntil returns true when the event has already happened: an event closure that returns at once, with a timeout of 5 s, gives true in well under the timeout (measured under 4 s) + - waitUntil returns true as soon as the event happens: an event that waits for the first element of an AsyncStream, yielded by another task after 20 ms, with a timeout of 30 s, gives true in under 5 s + - waitUntil returns false once the timeout has passed without the event (timeout 50 ms, an event that waits on an AsyncStream nobody yields to), and it does return, which proves the waiting event is cancelled + - A cancelled wait returns false at once instead of waiting for the timeout (timeout 30 s, measured under 5 s) - quitTimeout is 10 seconds -BUG-02. Start from the pushed branch head as in Task 1, step 1. +BUG-02. Start from the pushed branch head as in Task 1, step 1. The wait is driven by the app's termination event, not by checking it on a timer (CLAUDE.md "Principles": no polling when an event exists; `NSRunningApplication.isTerminated` is key-value observable, and today's code already observes it). -1. In `Ice/Core/SpacingRelaunch.swift` add, each with a `///` comment: +1. In `holzBar/Core/SpacingRelaunch.swift` add, each with a `///` comment: - `static let quitTimeout: Duration = .seconds(10)`: how long an app gets to quit after being asked. Long enough for an app that saves or syncs on quit; an app that is still running then is left alone and reported. - - `static let quitPollInterval: Duration = .milliseconds(100)`. - - `@MainActor static func waitUntil(timeout: Duration, pollInterval: Duration, _ condition: () -> Bool) async -> Bool`: checks `condition` first, then again after every `pollInterval`, measured on `ContinuousClock` against a deadline of now plus `timeout`. It returns true as soon as the condition holds and false once the deadline has passed. When `Task.sleep` throws (the task was cancelled), it returns the condition's current value at once; it never swallows the error and keeps looping. The doc comment states that it always returns, with no continuation to leak. -2. Add one `@Test` per behavior above to `SpacingRelaunchTests` (mark the suite `@MainActor` because `waitUntil` is main-actor isolated). Name two of them exactly `@Test("Waiting gives up at the timeout")` and `@Test("A cancelled wait returns at once")`. For the cancellation test start the wait in a `Task { @MainActor in ... }`, cancel it, await its value, and check both the result and that it took less than 5 seconds. + - `static func waitUntil(timeout: Duration, _ event: @escaping @Sendable () async -> Void) async -> Bool` (not actor-isolated): runs `event` and a `Task.sleep(for: timeout)` as the two child tasks of one `withTaskGroup(of: Bool.self)`, takes the first result, cancels the other child and returns. The event child reports true only when `event` returned and its task was not cancelled; the sleep child reports false. So it returns true as soon as the event happens, false once the timeout has passed, and false at once when the waiting task is cancelled. The doc comment states that it always returns, with no continuation to leak and nothing polled, and that `event` must return when its task is cancelled (iterating an `AsyncStream` does), because the group waits for both children. +2. Add one `@Test` per behavior above to `SpacingRelaunchTests`. Name two of them exactly `@Test("Waiting gives up at the timeout")` and `@Test("A cancelled wait returns at once")`. Build the events from `AsyncStream.makeStream(of: Void.self)` and a closure that returns at the stream's first element. For the cancellation test start the wait in a `Task`, cancel it, await its value, and check both the result and that it took less than 5 seconds (`ContinuousClock`). 3. In `MenuBarItemSpacingManager`: - - Replace the quit helper with `private func quit(_ app: NSRunningApplication) async -> Bool`: true at once when the app is already terminated (keep that debug log); otherwise log, call `app.terminate()`, and return `await SpacingRelaunch.waitUntil(timeout: SpacingRelaunch.quitTimeout, pollInterval: SpacingRelaunch.quitPollInterval) { app.isTerminated }`. When it returns false, log at debug level that the app did not quit within the timeout and is left running. Delete the force-termination fallback, the delay property that drove it, the Combine sink and the checked throwing continuation. Remove `import Combine` when nothing else in the file uses it. + - Replace the quit helper with `private func quit(_ app: NSRunningApplication) async -> Bool`: true at once when the app is already terminated (keep that debug log). Otherwise make a stream with `AsyncStream.makeStream(of: Void.self)` and observe the app with Foundation key-value observation, `app.observe(\.isTerminated, options: [.initial, .new])`, whose change handler yields to the stream and finishes it once `isTerminated` is true (`.initial` covers an app that quits before the observation starts). Then log, call `app.terminate()`, and wait with `SpacingRelaunch.waitUntil(timeout: SpacingRelaunch.quitTimeout)` on a closure that returns at the stream's first element; the closure captures only the stream (which is Sendable), never the app, so the build shows no Sendable warning. Afterwards invalidate the observation and finish the stream, and return the wait's result or `app.isTerminated`. When the result is false, log at debug level that the app did not quit within the timeout and is left running. Delete the force-termination fallback, the delay property that drove it, the Combine sink and the checked throwing continuation. Remove `import Combine` when nothing else in the file uses it. - `relaunchApp(_:)`: throw `RelaunchError` when `quit` returns false; launch otherwise, as today. - Control Center tail: when `quit` returns false, add its name to `failedApps`. - `GroupedRelaunchError.errorDescription`: "The following applications did not quit within N seconds and were not restarted:" followed by the names, with N taken from `SpacingRelaunch.quitTimeout` (`components.seconds`). Keep the recovery suggestion. 4. Commit (for example `fix(02-01): wait for apps to quit instead of killing them`), fetch and rebase if the branch moved, push, wait for CI and fix until build, test and swiftlint are green, as in Task 1 step 8. - ! grep -n 'forceTerminate' Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift && ! grep -n 'withCheckedThrowingContinuation' Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift && grep -q 'SpacingRelaunch.waitUntil' Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift && grep -q 'SpacingRelaunch.quitTimeout' Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift && grep -qE 'static let quitTimeout: Duration = \.seconds\(10\)' Ice/Core/SpacingRelaunch.swift && grep -q '@MainActor' Ice/Core/SpacingRelaunch.swift && grep -q 'static func waitUntil' Ice/Core/SpacingRelaunch.swift && test "$(grep -c '@Test' Tests/IceCoreTests/SpacingRelaunchTests.swift)" -ge 10 && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && grep -q 'Test "Waiting gives up at the timeout" passed' "$D/test.log" && grep -q 'Test "A cancelled wait returns at once" passed' "$D/test.log" && ! grep -E '(Ice/Core/[A-Za-z0-9]+|Ice/MenuBar/Spacing/MenuBarItemSpacingManager)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "spacing wait green on $H" - On the Mac (macOS 26.7.1): run an app that has a menu bar item and asks before it quits (a menu bar app with a "confirm before quitting" option, or a sync client in the middle of a sync), and leave its question unanswered. Apply a spacing offset. That app is not killed; after about 10 seconds holzIce shows "did not quit within 10 seconds and were not restarted" naming it, and the app is still running. The other apps relaunch as before. Later, on macOS 27: Apply a spacing offset; no alert names MenuBarAgent, the apps with menu bar items relaunch. Note in the UAT whether the spacing between items changes on macOS 27 (if it does not, MenuBarAgent reads the setting itself; open a follow-up). + ! grep -n 'forceTerminate' holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift && ! grep -n 'withCheckedThrowingContinuation' holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift && grep -q 'SpacingRelaunch.waitUntil' holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift && grep -q 'SpacingRelaunch.quitTimeout' holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift && grep -qE 'static let quitTimeout: Duration = \.seconds\(10\)' holzBar/Core/SpacingRelaunch.swift && grep -q 'static func waitUntil(timeout: Duration' holzBar/Core/SpacingRelaunch.swift && grep -q 'withTaskGroup' holzBar/Core/SpacingRelaunch.swift && ! grep -n 'pollInterval' holzBar/Core/SpacingRelaunch.swift holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift && grep -qF 'observe(\.isTerminated' holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift && test "$(grep -c '@Test' Tests/HolzBarCoreTests/SpacingRelaunchTests.swift)" -ge 10 && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && grep -q 'Test "Waiting gives up at the timeout" passed' "$D/test.log" && grep -q 'Test "A cancelled wait returns at once" passed' "$D/test.log" && ! grep -E '(holzBar/Core/[A-Za-z0-9]+|holzBar/MenuBar/Spacing/MenuBarItemSpacingManager)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "spacing wait green on $H" + On the Mac (macOS 26.7.1): run an app that has a menu bar item and asks before it quits (a menu bar app with a "confirm before quitting" option, or a sync client in the middle of a sync), and leave its question unanswered. Apply a spacing offset. That app is not killed; after about 10 seconds holzBar shows "did not quit within 10 seconds and were not restarted" naming it, and the app is still running. The other apps relaunch as before. Later, on macOS 27: Apply a spacing offset; no alert names MenuBarAgent, the apps with menu bar items relaunch. Note in the UAT whether the spacing between items changes on macOS 27 (if it does not, MenuBarAgent reads the setting itself; open a follow-up). - MenuBarItemSpacingManager.swift no longer force-terminates anything and has no checked continuation; quitting waits through SpacingRelaunch.waitUntil for up to SpacingRelaunch.quitTimeout (10 s) and reports apps that are still running. The SpacingRelaunch suite, including "Waiting gives up at the timeout" and "A cancelled wait returns at once", passes in CI; build, test and swiftlint are green on the PR head with no warning in the changed files. + MenuBarItemSpacingManager.swift no longer force-terminates anything and has no checked continuation; quitting waits through SpacingRelaunch.waitUntil, driven by key-value observation of isTerminated rather than polling, for up to SpacingRelaunch.quitTimeout (10 s) and reports apps that are still running. The SpacingRelaunch suite, including "Waiting gives up at the timeout" and "A cancelled wait returns at once", passes in CI; build, test and swiftlint are green on the PR head with no warning in the changed files. @@ -180,22 +182,22 @@ BUG-02. Start from the pushed branch head as in Task 1, step 1. | Boundary | Description | |----------|-------------| -| holzIce → other running apps | Applying spacing asks every app with a menu bar item to quit and reopens it; the user's unsaved work in those apps is at stake | -| holzIce → system processes | Control Center and, on macOS 27, MenuBarAgent own menu bar items but are not apps holzIce may quit and reopen | +| holzBar → other running apps | Applying spacing asks every app with a menu bar item to quit and reopens it; the user's unsaved work in those apps is at stake | +| holzBar → system processes | Control Center and, on macOS 27, MenuBarAgent own menu bar items but are not apps holzBar may quit and reopen | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-02-01 | Tampering | MenuBarItemSpacingManager quit path (user data in other apps) | high | mitigate | Delete the force-termination fallback; apps get `SpacingRelaunch.quitTimeout` (10 s) and are left running and reported when they do not quit (Task 2) | -| T-02-02 | Denial of Service | Spacing relaunch on macOS 27 (MenuBarAgent hosts the whole bar) | medium | mitigate | `SpacingRelaunch.processesToRelaunch` never returns MenuBarAgent, Control Center or holzIce itself; covered by tests (Task 1) | -| T-02-03 | Denial of Service | Waiting for an app to quit (hung settings pane, leaked task) | medium | mitigate | `SpacingRelaunch.waitUntil` always returns (condition, deadline or cancellation), with no continuation; covered by tests (Task 2) | -| T-02-SC | Tampering | Package.swift / dependencies | low | mitigate | Only local targets (`IceCore`, `IceCoreTests`) are added; no package dependency, no npm/pip/cargo install, so no package-legitimacy checkpoint applies | +| T-02-02 | Denial of Service | Spacing relaunch on macOS 27 (MenuBarAgent hosts the whole bar) | medium | mitigate | `SpacingRelaunch.processesToRelaunch` never returns MenuBarAgent, Control Center or holzBar itself; covered by tests (Task 1) | +| T-02-03 | Denial of Service | Waiting for an app to quit (hung settings pane, leaked task) | medium | mitigate | `SpacingRelaunch.waitUntil` always returns (event, deadline or cancellation), with no continuation and no polling; covered by tests (Task 2) | +| T-02-SC | Tampering | Package.swift / dependencies | low | mitigate | Only local targets (`HolzBarCore`, `HolzBarCoreTests`) are added; no package dependency, no npm/pip/cargo install, so no package-legitimacy checkpoint applies | - Both task verify commands pass on the final head. -- `git diff origin/main -- .github Ice.xcodeproj` is empty (this plan changes no workflow and no project file). +- `git diff origin/main -- .github holzBar.xcodeproj` is empty (this plan changes no workflow and no project file). - The PR is open, draft, base `main`, head `claude/ice-fork-development-hzdl1d`. @@ -203,7 +205,7 @@ BUG-02. Start from the pushed branch head as in Task 1, step 1. - Applying menu bar item spacing relaunches every affected app; a skipped process no longer ends the loop (BUG-01). - Apps get 10 seconds to quit, are never force-terminated, and the wait always returns (BUG-02). - On macOS 27 the owners come from the Accessibility read and MenuBarAgent is never quit (BUG-03). -- `swift test` runs the new IceCore tests in CI; the phase PR is open with build, test and swiftlint green. +- `swift test` runs the new HolzBarCore tests in CI; the phase PR is open with build, test and swiftlint green. diff --git a/.planning/phases/02-bug-fixes/02-02-PLAN.md b/.planning/phases/02-bug-fixes/02-02-PLAN.md index cec35a6e..4b222709 100644 --- a/.planning/phases/02-bug-fixes/02-02-PLAN.md +++ b/.planning/phases/02-bug-fixes/02-02-PLAN.md @@ -5,15 +5,15 @@ type: execute wave: 2 depends_on: ["02-01"] files_modified: - - Ice/Core/Modifiers.swift - - Ice/Hotkeys/ModifierFlags.swift - - Ice/UI/Views/HotkeyRecorder.swift - - Ice/Hotkeys/HotkeyRegistry.swift - - Tests/IceCoreTests/ModifiersTests.swift - - Ice/Permissions/Permission.swift - - Ice/Permissions/PermissionsView.swift + - holzBar/Core/Modifiers.swift + - holzBar/Hotkeys/ModifierFlags.swift + - holzBar/UI/Views/HotkeyRecorder.swift + - holzBar/Hotkeys/HotkeyRegistry.swift + - Tests/HolzBarCoreTests/ModifiersTests.swift + - holzBar/Permissions/Permission.swift + - holzBar/Permissions/PermissionsView.swift files_deleted: - - Ice/Hotkeys/Modifiers.swift + - holzBar/Hotkeys/Modifiers.swift autonomous: true requirements: [BUG-05, BUG-07] @@ -31,32 +31,32 @@ must_haves: - "HotkeyRegistry logs a clear reason instead of calling RegisterEventHotKey for an Option-only combination on macOS 15 and later" - "Waiting for a permission any number of times at once never hangs: every wait returns true once the permission is granted, or false when the checks stop or the waiting task is cancelled, and a false result does not reopen the permissions window" artifacts: - - path: Ice/Core/Modifiers.swift + - path: holzBar/Core/Modifiers.swift provides: "The Modifiers option set and the registration rule, pure and unit tested" contains: "func rejection(refusesOptionOnly" - - path: Ice/Hotkeys/ModifierFlags.swift + - path: holzBar/Hotkeys/ModifierFlags.swift provides: "Conversions between Modifiers and NSEvent, CGEvent and Carbon flags" contains: "carbonFlags" - - path: Tests/IceCoreTests/ModifiersTests.swift + - path: Tests/HolzBarCoreTests/ModifiersTests.swift provides: "Swift Testing suite for Modifiers" contains: "@Suite(\"Modifiers\")" - - path: Ice/UI/Views/HotkeyRecorder.swift + - path: holzBar/UI/Views/HotkeyRecorder.swift provides: "Recorder that refuses Option-only combinations on macOS 15+ and tells the user" contains: "rejection(refusesOptionOnly" - - path: Ice/Permissions/Permission.swift + - path: holzBar/Permissions/Permission.swift provides: "Permission wait that always returns" contains: "func waitForPermission() async -> Bool" key_links: - - from: Ice/UI/Views/HotkeyRecorder.swift - to: Ice/Core/Modifiers.swift + - from: holzBar/UI/Views/HotkeyRecorder.swift + to: holzBar/Core/Modifiers.swift via: "handleKeyDown asks the modifiers for their rejection before accepting a combination" pattern: "modifiers\\.rejection\\(refusesOptionOnly" - - from: Ice/Hotkeys/HotkeyRegistry.swift - to: Ice/Core/Modifiers.swift + - from: holzBar/Hotkeys/HotkeyRegistry.swift + to: holzBar/Core/Modifiers.swift via: "register() refuses Option-only combinations on macOS 15+ with a clear log" pattern: "rejection\\(refusesOptionOnly: true\\)" - - from: Ice/Permissions/PermissionsView.swift - to: Ice/Permissions/Permission.swift + - from: holzBar/Permissions/PermissionsView.swift + to: holzBar/Permissions/Permission.swift via: "both Grant buttons only reopen the window when the wait returned true" pattern: "guard await permission\\.waitForPermission\\(\\) else" --- @@ -64,8 +64,8 @@ must_haves: Make the hotkey recorder refuse what macOS 15+ cannot register and say so (BUG-05), and make waiting for a permission safe to call any number of times (BUG-07). -Purpose: since macOS 15, `RegisterEventHotKey` rejects hotkeys whose only modifiers are Option, or Option and Shift. holzIce records such a combination anyway, the registration fails, and the user only sees "Record Hotkey" again with no reason; the failure is only logged. Separately, `Permission.waitForPermission()` keeps one waiter in a stored property: a second call replaces it, so the first continuation is never resumed and its task hangs, and `stopCheck()` drops a pending waiter the same way. -Output: `Modifiers` moved into the pure, tested `Ice/Core` folder with the registration rule; the recorder and registry using it; `Permission.waitForPermission()` returning a Bool on its own subscription; the PermissionsView callers checking it. +Purpose: since macOS 15, `RegisterEventHotKey` rejects hotkeys whose only modifiers are Option, or Option and Shift. holzBar records such a combination anyway, the registration fails, and the user only sees "Record Hotkey" again with no reason; the failure is only logged. Separately, `Permission.waitForPermission()` keeps one waiter in a stored property: a second call replaces it, so the first continuation is never resumed and its task hangs, and `stopCheck()` drops a pending waiter the same way. +Output: `Modifiers` moved into the pure, tested `holzBar/Core` folder with the registration rule; the recorder and registry using it; `Permission.waitForPermission()` returning a Bool from its own stream; the PermissionsView callers checking it. @@ -81,14 +81,14 @@ Output: `Modifiers` moved into the pure, tested `Ice/Core` folder with the regis @.planning/codebase/TESTING.md @.planning/phases/02-bug-fixes/02-01-SUMMARY.md @Package.swift -@Ice/Hotkeys/Modifiers.swift -@Ice/Hotkeys/KeyCombination.swift -@Ice/Hotkeys/HotkeyRegistry.swift -@Ice/UI/Views/HotkeyRecorder.swift -@Ice/Permissions/Permission.swift -@Ice/Permissions/PermissionsView.swift +@holzBar/Hotkeys/Modifiers.swift +@holzBar/Hotkeys/KeyCombination.swift +@holzBar/Hotkeys/HotkeyRegistry.swift +@holzBar/UI/Views/HotkeyRecorder.swift +@holzBar/Permissions/Permission.swift +@holzBar/Permissions/PermissionsView.swift -The 02-01 SUMMARY is referenced for the PR number and for the `IceCore` / `IceCoreTests` targets (`Ice/Core`, `Tests/IceCoreTests`) that this plan adds files to; Package.swift needs no change here. +The 02-01 SUMMARY is referenced for the PR number and for the `HolzBarCore` / `HolzBarCoreTests` targets (`holzBar/Core`, `Tests/HolzBarCoreTests`) that this plan adds files to; Package.swift needs no change here. User decisions (orchestrator, 2026-10-02). Cite them by ID: - D-01: BUG-05 changes only the recorder's validation and feedback. The Carbon hotkey signature `OSType(1231250720)` stays identical to Ice's (both apps never run in parallel). @@ -96,24 +96,28 @@ User decisions (orchestrator, 2026-10-02). Cite them by ID: - D-05: One PR for the whole phase, from `claude/ice-fork-development-hzdl1d` to `main`. Push with `git push origin HEAD:claude/ice-fork-development-hzdl1d` (never force). The orchestrator marks the PR ready and merges it. Everything in the repository is English. Facts gathered while planning (2026-10-02). Do not re-derive them: -- No Mac, no compiler here; CI (`build`, `test`, `swiftlint`) on the phase PR is the only build. REST only for `gh`. Waiting on CI: foreground `sleep` is blocked; poll with a background until-loop or the Monitor tool every 30 s, for at most 30 minutes, until the three check runs of the PR head have `status == "completed"`; on failure read the job log (`gh api repos/holzcloud/holzIce/actions/jobs//logs`), fix, push, wait again. +- No Mac, no compiler here; CI (`build`, `test`, `swiftlint`) on the phase PR is the only build. REST only for `gh`. Waiting on CI: foreground `sleep` is blocked; poll with a background until-loop or the Monitor tool every 30 s, for at most 30 minutes, until the three check runs of the PR head have `status == "completed"`; on failure read the job log (`gh api repos/holzcloud/holzBar/actions/jobs//logs`), fix, push, wait again. The `build` job ends with the step "Check the identifiers" (built app and XPC service identifiers against `MenuBarItemService.name`); the `cask` workflow runs only when a PR touches `Casks/**`, `cask_renames.json` or `cask.yml`, which this phase does not, so the readback ignores it. +- The GitHub repository is now `holzcloud/holzBar`; through this session's proxy the old name `repos/holzcloud/holzIce` fails (its redirect goes to a numeric `repositories/` path the proxy refuses), so every `gh api` call uses `repos/holzcloud/holzBar`. git follows GitHub's redirect for the `origin` remote; if a push fails because of the old name, `git remote set-url origin https://github.com/holzcloud/holzBar` and push again. +- Names after the rename (Phase 01.1): source folder `holzBar/`, module `holzBar`, test package `HolzBarMacOS27Core`, bundle identifier `com.holzcloud.holzBar`. `.planning/codebase/*.md` predate the rename: read their `Ice/` as `holzBar/`. +- CLAUDE.md "Principles" bind every change (modern, lean, private, least privilege). This plan adds no Combine pipeline (no subject, sink or operator): the permission wait uses Swift concurrency (`AsyncStream`), and the recorder swaps its one `@Published` Bool for one `@Published` optional and uses SwiftUI's current `alert(_:isPresented:presenting:actions:message:)`. `HotkeyRecorderModel` and `Permission` stay `ObservableObject`s here because they are wired to `Hotkey`, `AppPermissions` and their views through Combine; moving models to `@Observable` is MOD-02 (Phase 05.1), which converts them together. Nothing here adds a permission, an entitlement or a network call, and no log line carries personal data. - Log formats: `✔ Test "Display name" passed after ...`, `✔ Suite "Name" passed after ...`, `Test run with N tests in M suites passed`; the build job lists warnings as `path/File.swift:L:C: warning: ...` before `** BUILD SUCCEEDED **`. -- `Modifiers` (`Ice/Hotkeys/Modifiers.swift`) is `struct Modifiers: OptionSet, Codable, Hashable` with `rawValue: Int` and `control = 1 << 0`, `option = 1 << 1`, `shift = 1 << 2`, `command = 1 << 3`, plus `canonicalOrder`, `symbolicValue` (pure) and conversions to and from `NSEvent.ModifierFlags`, `CGEventFlags` and Carbon flags (AppKit/Carbon). `KeyCombination` encodes `modifiers.rawValue`; `Ice/Utilities/Migration.swift:148` builds `Modifiers(rawValue:)` from Ice's stored values. Users of the type: `KeyCombination.swift`, `HotkeyRegistry.swift`, `Migration.swift`, `HotkeyRecorder.swift`. -- Swift refuses two files with the same base name in one target, and the app compiles everything under `Ice/` (synchronized group). So the pure part cannot live in a second `Modifiers.swift`; the AppKit part moves to `Ice/Hotkeys/ModifierFlags.swift`. +- `Modifiers` (`holzBar/Hotkeys/Modifiers.swift`) is `struct Modifiers: OptionSet, Codable, Hashable` with `rawValue: Int` and `control = 1 << 0`, `option = 1 << 1`, `shift = 1 << 2`, `command = 1 << 3`, plus `canonicalOrder`, `symbolicValue` (pure) and conversions to and from `NSEvent.ModifierFlags`, `CGEventFlags` and Carbon flags (AppKit/Carbon). `KeyCombination` encodes `modifiers.rawValue`; `holzBar/Utilities/Migration.swift:218` builds `Modifiers(rawValue:)` from Ice's stored values. Users of the type: `KeyCombination.swift`, `HotkeyRegistry.swift`, `Migration.swift`, `HotkeyRecorder.swift`. +- Swift refuses two files with the same base name in one target, and the app compiles everything under `holzBar/` (synchronized group). So the pure part cannot live in a second `Modifiers.swift`; the AppKit part moves to `holzBar/Hotkeys/ModifierFlags.swift`. - `HotkeyRecorderModel.handleKeyDown(event:)` today: no modifier → Escape stops recording, any other key beeps; Shift alone → beep; system-reserved → `isPresentingSystemReservedError = true` and recording continues; otherwise the combination is stored and recording stops. The view has one `.alert("Hotkey is reserved by macOS", isPresented:)` with an OK button and no message. - `Hotkey.isEnabled` is false when registration failed, which is why a failed Option-only hotkey just shows "Record Hotkey". -- `waitForPermission()` is called from the two Grant buttons in `PermissionsView.permissionBox(_:)`, each in a `Task` that then calls `appState.activate(withPolicy: .regular)` and `appState.openWindow(.permissions)`. `AppPermissions.stopAllChecks()` (called from `AppState.setupTask` after Continue) calls `stopCheck()` on every permission. Permission polling itself (1 s timer) stays as it is; stopping it once everything is granted is Phase 5 (PERF-02). -- SwiftLint (`Ice/` only, `--strict`): file header with the file name and `Ice`, mandatory trailing commas, caseless enums for static-only types, no force unwrapping, `///` docs on types and members. +- `waitForPermission()` is called from the two Grant buttons in `PermissionsView.permissionBox(_:)`, each in a `Task` that then calls `appState.activate(withPolicy: .regular)` and `appState.openWindow(.permissions)`. `AppPermissions.stopAllChecks()` (called from `AppState.setupTask` after Continue) calls `stopCheck()` on every permission. Permission polling itself (1 s timer) stays as it is: macOS posts no notification when a Screen Recording grant changes, and stopping the timer once everything is granted is Phase 5 (PERF-02). +- `HotkeyRegistry.swift` today has the comment line ` // The same four-character code as the original Ice's; the two apps never run together.` directly above ` private let signature = OSType(1231250720)` (Phase 01.1 moved the comment off the signature line). +- SwiftLint (`holzBar/` only, `--strict`): file header with the file name and `holzBar`, mandatory trailing commas, caseless enums for static-only types, no force unwrapping, `///` docs on types and members. CI readback used by both verifies (`$D` holds the three job logs afterwards): -`git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log"` +`git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log"` Task 1: the recorder refuses Option-only hotkeys on macOS 15+ and says why - Ice/Core/Modifiers.swift (new), Ice/Hotkeys/Modifiers.swift renamed to Ice/Hotkeys/ModifierFlags.swift, Tests/IceCoreTests/ModifiersTests.swift (new), Ice/UI/Views/HotkeyRecorder.swift, Ice/Hotkeys/HotkeyRegistry.swift + holzBar/Core/Modifiers.swift (new), holzBar/Hotkeys/Modifiers.swift renamed to holzBar/Hotkeys/ModifierFlags.swift, Tests/HolzBarCoreTests/ModifiersTests.swift (new), holzBar/UI/Views/HotkeyRecorder.swift, holzBar/Hotkeys/HotkeyRegistry.swift - [] gives .missing, whatever the macOS version - [.shift] gives .shiftOnly @@ -126,45 +130,46 @@ CI readback used by both verifies (`$D` holds the three job logs afterwards): BUG-05, recorder only (D-01). Start from the pushed branch head: `git fetch origin`, then rebase onto `origin/claude/ice-fork-development-hzdl1d` if HEAD does not contain it. -1. `git mv Ice/Hotkeys/Modifiers.swift Ice/Hotkeys/ModifierFlags.swift` and change its header line to `// ModifierFlags.swift`. Move the type declaration (the struct with `rawValue` and the four static members, with the same conformances and raw values), `canonicalOrder` and `symbolicValue` out of it into a new `Ice/Core/Modifiers.swift` (SwiftLint header, `import Foundation` only). `ModifierFlags.swift` keeps only the conversion extension (`nsEventFlags`, `cgEventFlags`, `carbonFlags` and the three initializers) and its Carbon/Cocoa imports, with a `///` comment saying these convert to and from system flags and that the type lives in `Ice/Core/Modifiers.swift` so it can be unit tested. Do not change `KeyCombination`, its coding, or `Migration.swift`: stored hotkeys and imported Ice hotkeys must decode the same. -2. In `Ice/Core/Modifiers.swift` add, in an extension of `Modifiers`: +1. `git mv holzBar/Hotkeys/Modifiers.swift holzBar/Hotkeys/ModifierFlags.swift` and change its header line to `// ModifierFlags.swift`. Move the type declaration (the struct with `rawValue` and the four static members, with the same conformances and raw values), `canonicalOrder` and `symbolicValue` out of it into a new `holzBar/Core/Modifiers.swift` (SwiftLint header, `import Foundation` only). `ModifierFlags.swift` keeps only the conversion extension (`nsEventFlags`, `cgEventFlags`, `carbonFlags` and the three initializers) and its Carbon/Cocoa imports, with a `///` comment saying these convert to and from system flags and that the type lives in `holzBar/Core/Modifiers.swift` so it can be unit tested. Do not change `KeyCombination`, its coding, or `Migration.swift`: stored hotkeys (including those copied from holzIce) and imported Ice hotkeys must decode the same. +2. In `holzBar/Core/Modifiers.swift` add, in an extension of `Modifiers`: - `enum Rejection: Equatable` with `missing` (no modifier: the key alone would fire on every press), `shiftOnly` (Shift alone: it would fire on every capital letter) and `optionOnly` (Option, or Option and Shift: macOS 15 and later refuse to register these with `RegisterEventHotKey`, so that a global hotkey cannot read typed text), each documented. - `func rejection(refusesOptionOnly: Bool) -> Rejection?`: `.missing` for no modifier, `.shiftOnly` for exactly Shift, `.optionOnly` for exactly Option or exactly Option and Shift when `refusesOptionOnly` is true, nil otherwise. Document that callers pass true on macOS 15 and later. -3. Create `Tests/IceCoreTests/ModifiersTests.swift` (`import Foundation`, `import Testing`, `@testable import IceCore`, `@Suite("Modifiers")`) with one `@Test` per behavior above. Name two exactly `@Test("Option alone is refused from macOS 15")` and `@Test("Raw values keep the stored hotkeys")`. +3. Create `Tests/HolzBarCoreTests/ModifiersTests.swift` (`import Foundation`, `import Testing`, `@testable import HolzBarCore`, `@Suite("Modifiers")`) with one `@Test` per behavior above. Name two exactly `@Test("Option alone is refused from macOS 15")` and `@Test("Raw values keep the stored hotkeys")`. 4. `HotkeyRecorder.swift`: - In `HotkeyRecorderModel`, replace the system-reserved Bool with `@Published var presentedProblem: Problem?`, where `Problem` is a nested enum with `systemReserved` and `optionOnly` and two computed strings. `systemReserved`: title "Hotkey is reserved by macOS", message "macOS uses this combination for one of its own shortcuts. Choose another one." `optionOnly`: title "macOS does not allow this hotkey", message "Since macOS 15, a hotkey whose only modifiers are Option, or Option and Shift, cannot be registered. Add Command or Control." Add a computed `isPresentingProblem: Bool` whose setter clears `presentedProblem` when set to false. - `handleKeyDown(event:)`: compute `refusesOptionOnly` as true inside `if #available(macOS 15.0, *)` and false otherwise, then switch over `keyCombination.modifiers.rejection(refusesOptionOnly:)`: `.missing` keeps today's Escape/beep behavior, `.shiftOnly` beeps, `.optionOnly` sets `presentedProblem = .optionOnly` and returns while recording continues (the user types another combination), nil falls through to the system-reserved check (which now sets `presentedProblem = .systemReserved`) and then stores the combination and stops recording, as today. - - View: one `.alert` driven by `$model.isPresentingProblem`, titled with the problem's title, with an OK button that clears the problem and the problem's message as its message. -5. `HotkeyRegistry.register(hotkey:eventKind:handler:)`: right after the key-combination guard, inside `if #available(macOS 15.0, *)`, when `keyCombination.modifiers.rejection(refusesOptionOnly: true) == .optionOnly`, log with `Logger.hotkeys.error` that macOS 15 and later do not register hotkeys whose only modifiers are Option, or Option and Shift, and return nil. Change nothing else in the file; the `signature` line stays byte for byte as it is (D-01). + - View: one `.alert(_:isPresented:presenting:actions:message:)` driven by `$model.isPresentingProblem` and presenting `model.presentedProblem`, titled with the problem's title, with an OK button that clears the problem and the problem's message as its message. +5. `HotkeyRegistry.register(hotkey:eventKind:handler:)`: right after the key-combination guard, inside `if #available(macOS 15.0, *)`, when `keyCombination.modifiers.rejection(refusesOptionOnly: true) == .optionOnly`, log with `Logger.hotkeys.error` that macOS 15 and later do not register hotkeys whose only modifiers are Option, or Option and Shift, and return nil. Change nothing else in the file; the `signature` line and the comment line above it stay byte for byte as they are (D-01). 6. Commit (for example `fix(02-02): refuse Option-only hotkeys on macOS 15 and tell the user`), push, wait for CI and fix until build, test and swiftlint are green; fix any warning in a file this task created or changed. Never disable, skip or delete a test (D-04). - test -f Ice/Core/Modifiers.swift && test -f Ice/Hotkeys/ModifierFlags.swift && test ! -e Ice/Hotkeys/Modifiers.swift && sed -n 2p Ice/Core/Modifiers.swift | grep -qx '// Modifiers.swift' && sed -n 2p Ice/Hotkeys/ModifierFlags.swift | grep -qx '// ModifierFlags.swift' && grep -q 'struct Modifiers: OptionSet, Codable, Hashable' Ice/Core/Modifiers.swift && ! grep -nE '^import (AppKit|Cocoa|SwiftUI|Carbon)' Ice/Core/Modifiers.swift && ! grep -n 'struct Modifiers' Ice/Hotkeys/ModifierFlags.swift && grep -q 'carbonFlags' Ice/Hotkeys/ModifierFlags.swift && grep -q 'nsEventFlags' Ice/Hotkeys/ModifierFlags.swift && grep -q 'cgEventFlags' Ice/Hotkeys/ModifierFlags.swift && grep -q 'func rejection(refusesOptionOnly' Ice/Core/Modifiers.swift && grep -q 'rejection(refusesOptionOnly' Ice/UI/Views/HotkeyRecorder.swift && grep -q 'macOS 15.0' Ice/UI/Views/HotkeyRecorder.swift && grep -q 'rejection(refusesOptionOnly: true)' Ice/Hotkeys/HotkeyRegistry.swift && grep -qF 'private let signature = OSType(1231250720) // OSType for Ice' Ice/Hotkeys/HotkeyRegistry.swift && GD=$(git diff origin/main -- Ice/Hotkeys/HotkeyRegistry.swift) && ! printf '%s\n' "$GD" | grep -E '^[-+][^-+].*OSType' && test "$(grep -c '@Test' Tests/IceCoreTests/ModifiersTests.swift)" -ge 7 && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && grep -q 'Suite "Modifiers" passed' "$D/test.log" && grep -q 'Test "Option alone is refused from macOS 15" passed' "$D/test.log" && grep -q 'Test "Raw values keep the stored hotkeys" passed' "$D/test.log" && ! grep -E '(Ice/Core/Modifiers|Ice/Hotkeys/ModifierFlags|Ice/Hotkeys/HotkeyRegistry|Ice/UI/Views/HotkeyRecorder)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "hotkey recorder green on $H" - On the Mac (macOS 26.7.1): holzIce Settings, Hotkeys. Press Record Hotkey and type Option-H: an alert "macOS does not allow this hotkey" explains that Command or Control is needed; after OK the recorder is still recording. Option-Shift-H: the same alert. Command-Option-H: recorded, and it triggers its action from another app. Hotkeys set before the update still work. + test -f holzBar/Core/Modifiers.swift && test -f holzBar/Hotkeys/ModifierFlags.swift && test ! -e holzBar/Hotkeys/Modifiers.swift && sed -n 2p holzBar/Core/Modifiers.swift | grep -qx '// Modifiers.swift' && sed -n 2p holzBar/Hotkeys/ModifierFlags.swift | grep -qx '// ModifierFlags.swift' && grep -q 'struct Modifiers: OptionSet, Codable, Hashable' holzBar/Core/Modifiers.swift && ! grep -nE '^import (AppKit|Cocoa|SwiftUI|Carbon)' holzBar/Core/Modifiers.swift && ! grep -n 'struct Modifiers' holzBar/Hotkeys/ModifierFlags.swift && grep -q 'carbonFlags' holzBar/Hotkeys/ModifierFlags.swift && grep -q 'nsEventFlags' holzBar/Hotkeys/ModifierFlags.swift && grep -q 'cgEventFlags' holzBar/Hotkeys/ModifierFlags.swift && grep -q 'func rejection(refusesOptionOnly' holzBar/Core/Modifiers.swift && grep -q 'rejection(refusesOptionOnly' holzBar/UI/Views/HotkeyRecorder.swift && grep -q 'macOS 15.0' holzBar/UI/Views/HotkeyRecorder.swift && grep -q 'rejection(refusesOptionOnly: true)' holzBar/Hotkeys/HotkeyRegistry.swift && grep -qxF ' private let signature = OSType(1231250720)' holzBar/Hotkeys/HotkeyRegistry.swift && GD=$(git diff origin/main -- holzBar/Hotkeys/HotkeyRegistry.swift) && ! printf '%s\n' "$GD" | grep -E '^[-+][^-+].*OSType' && test "$(grep -c '@Test' Tests/HolzBarCoreTests/ModifiersTests.swift)" -ge 7 && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && grep -q 'Suite "Modifiers" passed' "$D/test.log" && grep -q 'Test "Option alone is refused from macOS 15" passed' "$D/test.log" && grep -q 'Test "Raw values keep the stored hotkeys" passed' "$D/test.log" && ! grep -E '(holzBar/Core/Modifiers|holzBar/Hotkeys/ModifierFlags|holzBar/Hotkeys/HotkeyRegistry|holzBar/UI/Views/HotkeyRecorder)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "hotkey recorder green on $H" + On the Mac (macOS 26.7.1): holzBar Settings, Hotkeys. Press Record Hotkey and type Option-H: an alert "macOS does not allow this hotkey" explains that Command or Control is needed; after OK the recorder is still recording. Option-Shift-H: the same alert. Command-Option-H: recorded, and it triggers its action from another app. Hotkeys set before the update still work. - Modifiers lives in Ice/Core with the rejection rule and its Swift Testing suite, which passes in CI; the AppKit conversions live in Ice/Hotkeys/ModifierFlags.swift. The recorder refuses Option-only and Option+Shift-only combinations on macOS 15+ with an explaining alert and keeps recording; HotkeyRegistry logs the reason instead of calling RegisterEventHotKey for them. The signature line is unchanged. build, test and swiftlint are green on the PR head with no warning in the changed files. + Modifiers lives in holzBar/Core with the rejection rule and its Swift Testing suite, which passes in CI; the AppKit conversions live in holzBar/Hotkeys/ModifierFlags.swift. The recorder refuses Option-only and Option+Shift-only combinations on macOS 15+ with an explaining alert and keeps recording; HotkeyRegistry logs the reason instead of calling RegisterEventHotKey for them. The signature line is unchanged. build, test and swiftlint are green on the PR head with no warning in the changed files. Task 2: waiting for a permission twice, or after the checks stop, never hangs - Ice/Permissions/Permission.swift, Ice/Permissions/PermissionsView.swift + holzBar/Permissions/Permission.swift, holzBar/Permissions/PermissionsView.swift BUG-07. `Permission` imports Cocoa and calls Accessibility and screen capture checks, so it does not compile in the test package; this task is verified by the CI build and the human check (D-04 allows tests only where the code compiles there). 1. `Permission.swift`: - Delete the stored observer property that holds the single waiter (declared right after `timerCancellable`, documented as observing `hasPermission`) and every use of it. - - Add `private let checksStopped = PassthroughSubject()`, documented: tells pending waits that the checks have stopped. - - Change `waitForPermission()` to `@discardableResult func waitForPermission() async -> Bool`. Doc comment: returns true once the app has the permission (at once when it already has it), and false when `stopCheck()` ends the checks first or the waiting task is cancelled; every call waits on its own subscription, so any number of waits can run at the same time and each one returns. - - Body: call `configureCancellables()` first, as today (it restarts the checks). Then build one publisher: `$hasPermission` filtered to the first true value and mapped to true, merged with `checksStopped` mapped to false, then `.first()`. Iterate its `.values` with `for await` and return the first value. After the loop (only reached when the task is cancelled) return `hasPermission`. There is no checked continuation any more. - - `stopCheck()`: cancel the timer as today, then `checksStopped.send()`. + - Add `private var waiters: [UUID: AsyncStream.Continuation] = [:]`, documented: the pending waits, each with its own stream. Add a private `endWaits(with granted: Bool)` that yields `granted` to every waiter, finishes it and empties the dictionary. + - Give `hasPermission` a `didSet` that calls `endWaits(with: true)` when the new value is true and a wait is pending (the 1 s check sets it, so a grant ends every wait within a second, as today). + - Change `waitForPermission()` to `@discardableResult func waitForPermission() async -> Bool`. Doc comment: returns true once the app has the permission (at once when it already has it), and false when `stopCheck()` ends the checks first or the waiting task is cancelled; every call waits on its own stream, so any number of waits can run at the same time and each one returns. + - Body (main actor, as the class): call `configureCancellables()` first, as today (it restarts the checks); return true at once when `hasPermission` is true. Otherwise make a stream with `AsyncStream.makeStream(of: Bool.self)`, store its continuation under a new `UUID`, remove that entry again in a `defer`, iterate the stream with `for await` and return its first value. After the loop (only reached when the task is cancelled, which ends the stream's iteration) return `hasPermission`. There is no checked continuation any more, and no Combine subject or pipeline is added (CLAUDE.md "Principles": Swift concurrency for new code). + - `stopCheck()`: cancel the timer as today, then `endWaits(with: false)`. 2. `PermissionsView.swift`: in both Grant buttons' tasks ("Grant Permission" and "Reset and Grant Again"), write `guard await permission.waitForPermission() else { return }` before `appState.activate(withPolicy: .regular)` and `appState.openWindow(.permissions)`, so a wait that ended because the app moved on does not reopen the permissions window. 3. Commit (for example `fix(02-02): every permission wait returns`), push, wait for CI and fix until build, test and swiftlint are green; fix any warning in the two files. - ! grep -n 'hasPermissionCancellable' Ice/Permissions/Permission.swift && ! grep -n 'withCheckedContinuation' Ice/Permissions/Permission.swift && grep -qE 'func waitForPermission\(\) async -> Bool' Ice/Permissions/Permission.swift && grep -q '@discardableResult' Ice/Permissions/Permission.swift && grep -q 'PassthroughSubject' Ice/Permissions/Permission.swift && grep -q 'checksStopped.send()' Ice/Permissions/Permission.swift && grep -q '\.values' Ice/Permissions/Permission.swift && test "$(grep -c 'guard await permission.waitForPermission() else' Ice/Permissions/PermissionsView.swift)" -ge 2 && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && ! grep -E 'Ice/Permissions/Permissions?(View)?\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "permission wait green on $H" - On the Mac, with Screen Recording not yet granted (reset it with `tccutil reset ScreenCapture com.holzcloud.holzIce`): in the permissions window click "Grant Permission" for Screen Recording, then "Reset and Grant Again", then grant it in System Settings. The permissions window comes back to the front once and shows it as granted. Then reset it again, click "Grant Permission", do not grant it, and click Continue: the permissions window does not reopen by itself later. + ! grep -n 'hasPermissionCancellable' holzBar/Permissions/Permission.swift && ! grep -n 'withCheckedContinuation' holzBar/Permissions/Permission.swift && grep -qE 'func waitForPermission\(\) async -> Bool' holzBar/Permissions/Permission.swift && grep -q '@discardableResult' holzBar/Permissions/Permission.swift && grep -q 'AsyncStream.Continuation' holzBar/Permissions/Permission.swift && grep -q 'AsyncStream.makeStream(of: Bool.self)' holzBar/Permissions/Permission.swift && grep -q 'endWaits(with: false)' holzBar/Permissions/Permission.swift && ! grep -n 'PassthroughSubject' holzBar/Permissions/Permission.swift && test "$(grep -c 'guard await permission.waitForPermission() else' holzBar/Permissions/PermissionsView.swift)" -ge 2 && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && ! grep -E 'holzBar/Permissions/Permissions?(View)?\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "permission wait green on $H" + On the Mac, with Screen Recording not yet granted (reset it with `tccutil reset ScreenCapture com.holzcloud.holzBar`): in the permissions window click "Grant Permission" for Screen Recording, then "Reset and Grant Again", then grant it in System Settings. The permissions window comes back to the front once and shows it as granted. Then reset it again, click "Grant Permission", do not grant it, and click Continue: the permissions window does not reopen by itself later. - waitForPermission() returns a Bool from its own subscription, with no stored waiter and no checked continuation; stopCheck() ends pending waits with false; both callers in PermissionsView only reopen the permissions window on true. build, test and swiftlint are green on the PR head with no warning in the two files. + waitForPermission() returns a Bool from its own AsyncStream, with no single stored waiter, no checked continuation and no new Combine pipeline; stopCheck() ends pending waits with false; both callers in PermissionsView only reopen the permissions window on true. build, test and swiftlint are green on the PR head with no warning in the two files. @@ -175,23 +180,23 @@ BUG-07. `Permission` imports Cocoa and calls Accessibility and screen capture ch | Boundary | Description | |----------|-------------| | keyboard → global hotkeys | Carbon hotkeys see key presses system-wide; macOS 15 restricts which combinations an app may claim | -| stored settings → hotkeys | Hotkeys are decoded from holzIce's defaults and from imported Ice settings | +| stored settings → hotkeys | Hotkeys are decoded from holzBar's defaults, which may hold settings imported from holzIce or Ice | | System Settings (TCC) → permission waits | Grants arrive asynchronously; the app polls for them | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| -| T-02-04 | Denial of Service | Permission.waitForPermission (hung tasks, leaked continuations) | medium | mitigate | Each wait iterates its own publisher's `.values` and returns on grant, on `checksStopped` or on cancellation; no stored waiter (Task 2) | +| T-02-04 | Denial of Service | Permission.waitForPermission (hung tasks, leaked continuations) | medium | mitigate | Each wait iterates its own `AsyncStream` and returns on grant, when `stopCheck()` ends the waits, or on cancellation; no single stored waiter and no checked continuation (Task 2) | | T-02-05 | Tampering | Modifiers raw values (stored and imported hotkeys) | medium | mitigate | The type moves without changing conformances or raw values; "Raw values keep the stored hotkeys" pins 1/2/4/8 and the JSON round trip (Task 1) | -| T-02-06 | Information Disclosure | Option-only global hotkeys (macOS 15 anti-keylogging rule) | low | accept | holzIce only reports the restriction and refuses the combination; it adds no event tap or Input Monitoring workaround, so the OS protection stays intact | -| T-02-SC | Tampering | Package.swift / dependencies | low | mitigate | No package change and no new dependency; files are added to the existing local `IceCore` targets only | +| T-02-06 | Information Disclosure | Option-only global hotkeys (macOS 15 anti-keylogging rule) | low | accept | holzBar only reports the restriction and refuses the combination; it adds no event tap or Input Monitoring workaround, so the OS protection stays intact | +| T-02-SC | Tampering | Package.swift / dependencies | low | mitigate | No package change and no new dependency; files are added to the existing local `HolzBarCore` targets only | - Both task verify commands pass on the final head. -- `git diff origin/main -- Ice/Hotkeys/KeyCombination.swift Ice/Utilities/Migration.swift Package.swift .github` shows no change from this plan. -- `git diff origin/main -- Ice/Hotkeys/HotkeyRegistry.swift` adds and removes no line containing `OSType` (D-01). +- `git diff origin/main -- holzBar/Hotkeys/KeyCombination.swift holzBar/Utilities/Migration.swift Package.swift .github` shows no change from this plan. +- `git diff origin/main -- holzBar/Hotkeys/HotkeyRegistry.swift` adds and removes no line containing `OSType` (D-01). diff --git a/.planning/phases/02-bug-fixes/02-03-PLAN.md b/.planning/phases/02-bug-fixes/02-03-PLAN.md index 0bf0fbd3..ddb434ea 100644 --- a/.planning/phases/02-bug-fixes/02-03-PLAN.md +++ b/.planning/phases/02-bug-fixes/02-03-PLAN.md @@ -9,7 +9,7 @@ files_modified: - Package.swift - Tests/SharedCodeSigningTests/CodeSignatureTests.swift - MenuBarItemService/Listener.swift - - Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift + - holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift - Shared/Services/MenuBarItemService.swift - docs/upstream-bugs.md autonomous: true @@ -23,11 +23,11 @@ estimate: must_haves: truths: - - "On an ad hoc build (no team identifier) the XPC menu bar item service accepts holzIce: its listener requires the signing identifier com.holzcloud.holzIce and one of the code directory hashes of the app bundle it is embedded in" + - "On an ad hoc build (no team identifier) the XPC menu bar item service accepts holzBar: its listener requires the signing identifier com.holzcloud.holzBar and one of the code directory hashes of the app bundle it is embedded in" - "Every other process is rejected: a different binary has other hashes, and other ad hoc code that claims the identifier does not match; a team-signed build requires the same team and the signing identifier" - "If the service cannot build its requirement it does not listen at all (fail closed), and the app keeps its in-app fallback" - "A Swift Testing suite proves on the CI runner that the hashes CodeSignature reads match what LightweightCodeRequirements evaluates for a running process, and that other hashes or identifiers do not match" - - "The app binary does not link LightweightCodeRequirements (macOS 14.4+), so holzIce still launches on macOS 14.0" + - "The app binary does not link LightweightCodeRequirements (macOS 14.4+), so holzBar still launches on macOS 14.0" artifacts: - path: Shared/CodeSigning/CodeSignature.swift provides: "Team identifier of this process, signing identifier and per-slice code directory hashes of code on disk" @@ -38,7 +38,7 @@ must_haves: - path: MenuBarItemService/Listener.swift provides: "Listener peer requirement: same team, or the embedding app's exact code" contains: "CodeDirectoryHash" - - path: Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift + - path: holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift provides: "Client session without the team requirement on ad hoc builds" contains: "CodeSignature.currentTeamIdentifier" key_links: @@ -46,7 +46,7 @@ must_haves: to: Shared/CodeSigning/CodeSignature.swift via: "peerRequirement() reads the team of the service and the hashes of the embedding app" pattern: "CodeSignature\\.(currentTeamIdentifier|codeDirectoryHashes)" - - from: Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift + - from: holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift to: Shared/CodeSigning/CodeSignature.swift via: "getOrCreateSession() sets the team requirement only when the app has a team" pattern: "CodeSignature\\.currentTeamIdentifier" @@ -57,10 +57,10 @@ must_haves: --- -Make the macOS 26 menu bar item service accept holzIce's ad hoc builds while it keeps rejecting every other process (BUG-06, D-02). +Make the macOS 26 menu bar item service accept holzBar's ad hoc builds while it keeps rejecting every other process (BUG-06, D-02). -Purpose: the listener in the XPC service and the app's session both require a peer "from the same team". holzIce's releases and source builds are signed ad hoc and have no team identifier at all, so on macOS 26 the service can refuse holzIce itself; the app then falls back to its slower in-app lookup (the open "Loading menu bar items…" / `XPCRichError` reports in `docs/upstream-bugs.md`). -Chosen option (D-02, safest that works ad hoc): for code without a team, Apple's implicit designated requirement is its code directory hash (cdhash). The service therefore requires holzIce's signing identifier and one of the cdhashes of the app bundle it is embedded in: exactly that app's code is accepted, and every other process is rejected, including other ad hoc code that claims the same identifier. A team-signed build keeps the team check and adds the signing identifier. The hash reading is proven on the CI runner by a Swift Testing suite before the listener uses it. +Purpose: the listener in the XPC service and the app's session both require a peer "from the same team". holzBar's releases and source builds are signed ad hoc and have no team identifier at all, so on macOS 26 the service can refuse holzBar itself; the app then falls back to its slower in-app lookup (the open "Loading menu bar items…" / `XPCRichError` reports in `docs/upstream-bugs.md`). +Chosen option (D-02, safest that works ad hoc): for code without a team, Apple's implicit designated requirement is its code directory hash (cdhash). The service therefore requires holzBar's signing identifier and one of the cdhashes of the app bundle it is embedded in: exactly that app's code is accepted, and every other process is rejected, including other ad hoc code that claims the same identifier. A team-signed build keeps the team check and adds the signing identifier. The hash reading is proven on the CI runner by a Swift Testing suite before the listener uses it. Output: `Shared/CodeSigning/CodeSignature.swift` and its test target, the new listener requirement, the client session change, the updated `docs/upstream-bugs.md` row. @@ -79,7 +79,7 @@ Output: `Shared/CodeSigning/CodeSignature.swift` and its test target, the new li @Package.swift @MenuBarItemService/Listener.swift @MenuBarItemService/main.swift -@Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift +@holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift @Shared/Services/MenuBarItemService.swift @Shared/Utilities/Logging.swift @docs/upstream-bugs.md @@ -87,7 +87,7 @@ Output: `Shared/CodeSigning/CodeSignature.swift` and its test target, the new li The 02-01 SUMMARY is referenced for the PR number and the Package.swift layout that this plan extends. User decisions (orchestrator, 2026-10-02). Cite them by ID: -- D-02: BUG-06 keeps rejecting foreign processes. The XPC listener must accept holzIce on ad hoc builds (no team identifier), for example through a code-signing requirement on the bundle identifier `com.holzcloud.holzIce` combined with the host's signing identity or cdhash. Choose the safest option that works with ad hoc signing. +- D-02: BUG-06 keeps rejecting foreign processes. The XPC listener must accept holzBar on ad hoc builds (no team identifier), for example through a code-signing requirement on the bundle identifier `com.holzcloud.holzBar` combined with the host's signing identity or cdhash. Choose the safest option that works with ad hoc signing. (The decision was taken before Phase 01.1 and named `com.holzcloud.holzIce`; since the rename the app is `com.holzcloud.holzBar`, and the requirement uses that identifier.) - D-04: Where logic is testable and compiles in the test-only Package.swift without AppKit-heavy dependencies, add Swift Testing tests there. Never disable, skip or delete a test. - D-05: One PR for the whole phase, from `claude/ice-fork-development-hzdl1d` to `main`. Push with `git push origin HEAD:claude/ice-fork-development-hzdl1d` (never force). The orchestrator marks the PR ready and merges it. Everything in the repository is English. @@ -97,15 +97,19 @@ API facts checked against Apple's documentation while planning (2026-10-02). Do - Security (macOS 10.x): `SecCodeCopySelf`, `SecCodeCopyStaticCode`, `SecCodeCopyPath`, `SecStaticCodeCreateWithPath`, `SecStaticCodeCreateWithPathAndAttributes` with `kSecCodeAttributeArchitecture` (a string such as "arm64", "arm64e" or "x86_64" selects a slice of a universal binary), `SecStaticCodeCheckValidity`, `SecCodeCopySigningInformation` with `SecCSFlags(rawValue: kSecCSSigningInformation)`; keys `kSecCodeInfoIdentifier`, `kSecCodeInfoTeamIdentifier`, `kSecCodeInfoUnique` (the cdhash, returned with any flags) and `kSecCodeInfoCdHashes` (an array with one hash per digest algorithm). Repository facts. Do not re-derive them: -- App bundle identifier `com.holzcloud.holzIce`, service `com.holzcloud.holzIce.MenuBarItemService` (`MenuBarItemService.name`); Xcode signs with the bundle identifier as the signing identifier. CI, `Scripts/install.sh` and releases sign ad hoc (`CODE_SIGN_IDENTITY=-`, `DEVELOPMENT_TEAM=`, hardened runtime off); the Release build is universal (arm64 and x86_64). The cask does not re-sign. -- The service lives at `holzIce.app/Contents/XPCServices/MenuBarItemService.xpc`, so its `Bundle.main.bundleURL` is three path components below the app bundle. `main.swift` activates the listener; the app only connects on macOS 26 and later (`MenuBarItemService.Connection` is `@available(macOS 26.0, *)`), and launchd resolves an app's XPC service name only inside that app's own bundle. -- Files under `Shared/` compile into both the app and the service (synchronized groups); files under `MenuBarItemService/` only into the service. Both targets have deployment target macOS 14.0. LightweightCodeRequirements is macOS 14.4+, so importing it in the app would link the framework into a binary that must still launch on macOS 14.0 to 14.3. Keep it out of `Ice/` and `Shared/`; the service, which only runs on macOS 26, may import it. -- `Shared/`, `MenuBarItemService/` and `Tests/` are not linted (SwiftLint covers `Ice/` only); `Shared/` files use the header `//`, `// .swift`, `// Shared`, `//`. Keep the house style anyway (`///` docs, caseless enums for static-only types, no force unwraps). -- The service logs with `Logger.default` (`Shared/Utilities/Logging.swift`), subsystem = the process's bundle identifier. -- No Mac, no compiler here; CI (`build`, `test`, `swiftlint`) on the phase PR is the only build, and `swift test` runs on macOS 26, so macOS 15 APIs run there. REST only for `gh`. Waiting on CI: foreground `sleep` is blocked; poll with a background until-loop or the Monitor tool every 30 s, for at most 30 minutes, until the three check runs of the PR head have `status == "completed"`; on failure read the job log, fix, push, wait again. Log formats: `✔ Test "Display name" passed after ...`, `✔ Suite "Name" passed after ...`; warnings as `path/File.swift:L:C: warning: ...`. +- App bundle identifier `com.holzcloud.holzBar`, service `com.holzcloud.holzBar.MenuBarItemService` (`MenuBarItemService.name`); Xcode signs with the bundle identifier as the signing identifier. CI, `Scripts/install.sh` and releases sign ad hoc (`CODE_SIGN_IDENTITY=-`, `DEVELOPMENT_TEAM=`, hardened runtime off); the Release build is universal (arm64 and x86_64). The cask does not re-sign. +- The `build` job runs the step "Check the identifiers" after the build: it reads `MenuBarItemService.name` from `Shared/Services/MenuBarItemService.swift` with `sed -nE 's/^[[:space:]]*static let name = "([^"]+)".*/\1/p'` and fails unless the built service's identifier equals it and equals the app's identifier plus `.MenuBarItemService`. So `static let name = "com.holzcloud.holzBar.MenuBarItemService"` must stay a one-line string literal, and no other declaration in that file may start with `static let name =`. +- The service lives at `holzBar.app/Contents/XPCServices/MenuBarItemService.xpc`, so its `Bundle.main.bundleURL` is three path components below the app bundle. `main.swift` activates the listener; the app only connects on macOS 26 and later (`MenuBarItemService.Connection` is `@available(macOS 26.0, *)`), and launchd resolves an app's XPC service name only inside that app's own bundle. +- Files under `Shared/` compile into both the app and the service (synchronized groups); files under `MenuBarItemService/` only into the service. Both targets have deployment target macOS 14.0. LightweightCodeRequirements is macOS 14.4+, so importing it in the app would link the framework into a binary that must still launch on macOS 14.0 to 14.3. Keep it out of `holzBar/` and `Shared/`; the service, which only runs on macOS 26, may import it. +- `Shared/`, `MenuBarItemService/` and `Tests/` are not linted (SwiftLint covers `holzBar/` only); `Shared/` files use the header `//`, `// .swift`, `// Shared`, `//`. Keep the house style anyway (`///` docs, caseless enums for static-only types, no force unwraps). +- The service logs with `Logger.default` (`Shared/Utilities/Logging.swift`), which is `Logger(.default)`: it has no subsystem, so its messages are found by process (`process == "MenuBarItemService"`, the service's executable). Loggers made with `Logger(category:)` use the process's bundle identifier as subsystem (`com.holzcloud.holzBar` in the app). +- No Mac, no compiler here; CI (`build`, `test`, `swiftlint`) on the phase PR is the only build, and `swift test` runs on macOS 26, so macOS 15 APIs run there. REST only for `gh`. Waiting on CI: foreground `sleep` is blocked; poll with a background until-loop or the Monitor tool every 30 s, for at most 30 minutes, until the three check runs of the PR head have `status == "completed"`; on failure read the job log, fix, push, wait again. Log formats: `✔ Test "Display name" passed after ...`, `✔ Suite "Name" passed after ...`; warnings as `path/File.swift:L:C: warning: ...`. The `cask` workflow runs only when a PR touches `Casks/**`, `cask_renames.json` or `cask.yml`, which this phase does not, so the readback ignores it. +- The GitHub repository is now `holzcloud/holzBar`; through this session's proxy the old name `repos/holzcloud/holzIce` fails (its redirect goes to a numeric `repositories/` path the proxy refuses), so every `gh api` call uses `repos/holzcloud/holzBar`. git follows GitHub's redirect for the `origin` remote; if a push fails because of the old name, `git remote set-url origin https://github.com/holzcloud/holzBar` and push again. +- Names after the rename (Phase 01.1): source folder `holzBar/`, project `holzBar.xcodeproj`, module `holzBar`, test package `HolzBarMacOS27Core`, product `holzBar.app`. `.planning/codebase/*.md` predate the rename: read their `Ice/` as `holzBar/` and `Ice.xcodeproj` as `holzBar.xcodeproj`. +- CLAUDE.md "Principles" bind every change. Least privilege is the point of this plan: the service answers exactly one program, and nothing here adds a permission or an entitlement (code signing checks need none). Private: the new log line names only the kind of requirement and the number of hashes, never a path or user data. Lean: the hashes are read once, when the listener activates. CI readback used by both verifies (`$D` holds the three job logs afterwards): -`git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log"` +`git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log"` @@ -137,33 +141,33 @@ BUG-06, the part that can be proven before anything uses it (D-02, D-04). Start 4. Commit (for example `test(02-03): prove the code directory hashes that pin a process`), push, wait for CI. If the matching test fails, read `failureReason` in the test output and fix how the hashes are read (for example the slice selection or the hash list); do not loosen the test. Never disable, skip or delete a test (D-04). Get build, test and swiftlint green, with no warning in `Shared/CodeSigning/CodeSignature.swift`. - sed -n 2p Shared/CodeSigning/CodeSignature.swift | grep -qx '// CodeSignature.swift' && grep -q 'enum CodeSignature' Shared/CodeSigning/CodeSignature.swift && grep -q 'static let currentTeamIdentifier: String?' Shared/CodeSigning/CodeSignature.swift && grep -q 'static func codeDirectoryHashes(ofCodeAt' Shared/CodeSigning/CodeSignature.swift && grep -q 'kSecCodeAttributeArchitecture' Shared/CodeSigning/CodeSignature.swift && grep -q 'kSecCodeInfoUnique' Shared/CodeSigning/CodeSignature.swift && grep -q 'SecStaticCodeCheckValidity' Shared/CodeSigning/CodeSignature.swift && ! grep -nE '^import (XPC|LightweightCodeRequirements|AppKit|Cocoa)' Shared/CodeSigning/CodeSignature.swift && grep -q 'name: "SharedCodeSigning"' Package.swift && grep -q 'path: "Shared/CodeSigning"' Package.swift && grep -q 'name: "SharedCodeSigningTests"' Package.swift && grep -q 'path: "Ice/Core"' Package.swift && grep -q 'path: "Ice/MenuBar/MacOS27/Core"' Package.swift && test "$(grep -c '@Test' Tests/SharedCodeSigningTests/CodeSignatureTests.swift)" -ge 6 && grep -q 'SecCodeCheckValidityWithProcessRequirement' Tests/SharedCodeSigningTests/CodeSignatureTests.swift && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && grep -q 'Suite "CodeSignature" passed' "$D/test.log" && grep -q 'Test "A requirement on identifier and hashes matches this process" passed' "$D/test.log" && grep -q "Test \"Another program's hashes do not match\" passed" "$D/test.log" && ! grep -E 'Shared/CodeSigning/CodeSignature\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "code signature proof green on $H" + sed -n 2p Shared/CodeSigning/CodeSignature.swift | grep -qx '// CodeSignature.swift' && grep -q 'enum CodeSignature' Shared/CodeSigning/CodeSignature.swift && grep -q 'static let currentTeamIdentifier: String?' Shared/CodeSigning/CodeSignature.swift && grep -q 'static func codeDirectoryHashes(ofCodeAt' Shared/CodeSigning/CodeSignature.swift && grep -q 'kSecCodeAttributeArchitecture' Shared/CodeSigning/CodeSignature.swift && grep -q 'kSecCodeInfoUnique' Shared/CodeSigning/CodeSignature.swift && grep -q 'SecStaticCodeCheckValidity' Shared/CodeSigning/CodeSignature.swift && ! grep -nE '^import (XPC|LightweightCodeRequirements|AppKit|Cocoa)' Shared/CodeSigning/CodeSignature.swift && grep -q 'name: "SharedCodeSigning"' Package.swift && grep -q 'path: "Shared/CodeSigning"' Package.swift && grep -q 'name: "SharedCodeSigningTests"' Package.swift && grep -q 'path: "holzBar/Core"' Package.swift && grep -q 'path: "holzBar/MenuBar/MacOS27/Core"' Package.swift && test "$(grep -c '@Test' Tests/SharedCodeSigningTests/CodeSignatureTests.swift)" -ge 6 && grep -q 'SecCodeCheckValidityWithProcessRequirement' Tests/SharedCodeSigningTests/CodeSignatureTests.swift && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && grep -q 'Suite "CodeSignature" passed' "$D/test.log" && grep -q 'Test "A requirement on identifier and hashes matches this process" passed' "$D/test.log" && grep -q "Test \"Another program's hashes do not match\" passed" "$D/test.log" && ! grep -E 'Shared/CodeSigning/CodeSignature\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "code signature proof green on $H" Shared/CodeSigning/CodeSignature.swift exists without LightweightCodeRequirements or XPC imports and compiles into the app, the service and the SharedCodeSigning package target. On the CI runner the CodeSignature suite passed, including "A requirement on identifier and hashes matches this process" and "Another program's hashes do not match". build, test and swiftlint are green on the PR head with no warning in CodeSignature.swift. - Task 2: the service accepts holzIce's ad hoc build and still rejects everything else + Task 2: the service accepts holzBar's ad hoc build and still rejects everything else The peer policy lives in one function on each side; signing with a team later only changes which branch runs, no stored data or contract depends on it. - MenuBarItemService/Listener.swift, Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift, Shared/Services/MenuBarItemService.swift, docs/upstream-bugs.md + MenuBarItemService/Listener.swift, holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift, Shared/Services/MenuBarItemService.swift, docs/upstream-bugs.md BUG-06, wiring (D-02). Start from the pushed branch head as in Task 1. -1. `Shared/Services/MenuBarItemService.swift`: add `static let appIdentifier = "com.holzcloud.holzIce"` to the enum, documented as the signing identifier of holzIce, the only app the service answers (Xcode signs with the bundle identifier). +1. `Shared/Services/MenuBarItemService.swift`: add `static let appIdentifier = "com.holzcloud.holzBar"` to the enum, documented as the signing identifier of holzBar, the only app the service answers (Xcode signs with the bundle identifier). Put it on its own line below `name` and leave the `static let name = "com.holzcloud.holzBar.MenuBarItemService"` line exactly as it is: the build's "Check the identifiers" step reads that line (see the repository facts), and it already proves that the built app's identifier is the service name without `.MenuBarItemService`, which is `appIdentifier`. 2. `MenuBarItemService/Listener.swift` (service only): - Import LightweightCodeRequirements here, next to OSLog and XPC. This is the only file in the repository that imports it in app or service code. - Replace the same-team activation helper with `@available(macOS 26.0, *) private func uncheckedActivate(requirement: XPCPeerRequirement) throws`, which creates the `XPCListener` with that requirement and the same session handler as today. - Add `@available(macOS 26.0, *) private func peerRequirement() throws -> XPCPeerRequirement`. When `CodeSignature.currentTeamIdentifier` is not nil, return `.isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.appIdentifier)`. Otherwise (ad hoc, no team): take the app bundle three path components above `Bundle.main.bundleURL` (MenuBarItemService.xpc, XPCServices, Contents); throw a small error type with a readable description unless `Bundle(url:)` of it reports `MenuBarItemService.appIdentifier`; read `CodeSignature.codeDirectoryHashes(ofCodeAt:)` of it; return `.codeRequirement` of `ProcessCodeRequirement.allOf` with `SigningIdentifier(MenuBarItemService.appIdentifier)` and `CodeDirectoryHash.in(hashes)`. The doc comment explains the choice in two or three sentences: ad hoc code has no team, Apple's implicit designated requirement for such code is its cdhash, so the service accepts exactly the code of the app it is embedded in and rejects every other process, including ad hoc code that claims the same identifier. - `activate()`: on macOS 26 call `uncheckedActivate(requirement: peerRequirement())` inside the existing do/catch, and log with `Logger.default.notice` which requirement is in force (the team one, or the app's exact code with the number of hashes). On an error the existing catch logs it and the listener stays inactive: fail closed, and the app's in-app fallback takes over. Leave the pre-26 path as it is. 3. `MenuBarItemServiceConnection.swift`, `Session.Storage.getOrCreateSession()`: replace the unconditional bare same-team requirement. When `CodeSignature.currentTeamIdentifier` is not nil, call `session.setPeerRequirement(.isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.name))`; otherwise set none. Comment why: an ad hoc build has no team to compare; launchd resolves this service name only inside this app's own bundle, so no other code can answer, and the service pins this app's exact code in turn; LightweightCodeRequirements is not used in the app because it needs macOS 14.4 and the app still launches on 14.0. -4. `docs/upstream-bugs.md`: in the "Fixed in holzIce" row "Layout editor or Ice Bar stuck on "Loading menu bar items…" on macOS 26", append to the Fix cell: "; the service also accepts holzIce's own ad hoc builds, which have no team identifier, by requiring the exact code of the app it is embedded in (signing identifier and code directory hashes)". Change nothing else in the file (the report counts belong to Phase 3). -5. Commit (for example `fix(02-03): accept holzIce's ad hoc build in the menu bar item service`), push, wait for CI and fix until build, test and swiftlint are green with no warning in the changed Swift files. Then update the PR body's list of what has landed through `gh api -X PATCH repos/holzcloud/holzIce/pulls/ -f body=...` (keep the attribution lines at the end). +4. `docs/upstream-bugs.md`: in the "Fixed in holzBar" row "Layout editor or Ice Bar stuck on "Loading menu bar items…" on macOS 26", append to the Fix cell: "; the service also accepts holzBar's own ad hoc builds, which have no team identifier, by requiring the exact code of the app it is embedded in (signing identifier and code directory hashes)". Change nothing else in the file (the report counts belong to Phase 3). +5. Commit (for example `fix(02-03): accept holzBar's ad hoc build in the menu bar item service`), push, wait for CI and fix until build, test and swiftlint are green with no warning in the changed Swift files. Then update the PR body's list of what has landed through `gh api -X PATCH repos/holzcloud/holzBar/pulls/ -f body=...` (keep the attribution lines at the end). - grep -q 'static let appIdentifier = "com.holzcloud.holzIce"' Shared/Services/MenuBarItemService.swift && grep -q 'CodeSignature.codeDirectoryHashes(ofCodeAt' MenuBarItemService/Listener.swift && grep -q 'CodeDirectoryHash' MenuBarItemService/Listener.swift && grep -q 'SigningIdentifier(MenuBarItemService.appIdentifier)' MenuBarItemService/Listener.swift && grep -q 'isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.appIdentifier)' MenuBarItemService/Listener.swift && grep -q 'CodeSignature.currentTeamIdentifier' MenuBarItemService/Listener.swift && grep -q 'requirement: XPCPeerRequirement' MenuBarItemService/Listener.swift && grep -q 'isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.name)' Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift && grep -q 'CodeSignature.currentTeamIdentifier' Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift && ! grep -rn 'isFromSameTeam()' MenuBarItemService Ice Shared && ! grep -rln 'import LightweightCodeRequirements' Ice Shared && grep -q 'exact code of the app it is embedded in' docs/upstream-bugs.md && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && ! grep -E '(MenuBarItemService/Listener|Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection|Shared/CodeSigning/CodeSignature|Shared/Services/MenuBarItemService)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "XPC ad hoc acceptance green on $H" - On the Mac (macOS 26.7.1) with holzIce built by CI or `Scripts/install.sh` (ad hoc; `codesign -dv` shows `TeamIdentifier=not set`): start `log stream --level info --predicate 'subsystem BEGINSWITH "com.holzcloud.holzIce"'` in Terminal, then launch holzIce and open Settings, Menu Bar Layout. The service logs that it requires the app's exact code with at least one hash; the layout shows the menu bar items instead of "Loading menu bar items…"; no "looking up source processes in the app instead" line appears. + grep -q 'static let appIdentifier = "com.holzcloud.holzBar"' Shared/Services/MenuBarItemService.swift && test "$(grep -cE '^[[:space:]]*static let name = ' Shared/Services/MenuBarItemService.swift)" -eq 1 && grep -qx ' static let name = "com.holzcloud.holzBar.MenuBarItemService"' Shared/Services/MenuBarItemService.swift && grep -q 'CodeSignature.codeDirectoryHashes(ofCodeAt' MenuBarItemService/Listener.swift && grep -q 'CodeDirectoryHash' MenuBarItemService/Listener.swift && grep -q 'SigningIdentifier(MenuBarItemService.appIdentifier)' MenuBarItemService/Listener.swift && grep -q 'isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.appIdentifier)' MenuBarItemService/Listener.swift && grep -q 'CodeSignature.currentTeamIdentifier' MenuBarItemService/Listener.swift && grep -q 'requirement: XPCPeerRequirement' MenuBarItemService/Listener.swift && grep -q 'isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.name)' holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift && grep -q 'CodeSignature.currentTeamIdentifier' holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift && ! grep -rn 'isFromSameTeam()' MenuBarItemService holzBar Shared && ! grep -rln 'import LightweightCodeRequirements' holzBar Shared && grep -q 'exact code of the app it is embedded in' docs/upstream-bugs.md && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && ! grep -E '(MenuBarItemService/Listener|holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection|Shared/CodeSigning/CodeSignature|Shared/Services/MenuBarItemService)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "XPC ad hoc acceptance green on $H" + On the Mac (macOS 26.7.1) with holzBar built by CI or `Scripts/install.sh` (ad hoc; `codesign -dv` shows `TeamIdentifier=not set`): start `log stream --level info --predicate 'process == "MenuBarItemService" OR subsystem BEGINSWITH "com.holzcloud.holzBar"'` in Terminal, then launch holzBar and open Settings, Menu Bar Layout. The service logs that it requires the app's exact code with at least one hash; the layout shows the menu bar items instead of "Loading menu bar items…"; no "looking up source processes in the app instead" line appears. - On macOS 26 the listener requires holzIce's signing identifier plus the embedding app's code directory hashes on ad hoc builds (same team plus identifier on team builds) and does not listen when that requirement cannot be built; the app sets the team requirement only when it has a team. No bare same-team requirement remains, LightweightCodeRequirements is imported only in MenuBarItemService/Listener.swift, docs/upstream-bugs.md records the fix, and build, test and swiftlint are green on the PR head with no warning in the changed files. + On macOS 26 the listener requires holzBar's signing identifier plus the embedding app's code directory hashes on ad hoc builds (same team plus identifier on team builds) and does not listen when that requirement cannot be built; the app sets the team requirement only when it has a team. No bare same-team requirement remains, LightweightCodeRequirements is imported only in MenuBarItemService/Listener.swift, docs/upstream-bugs.md records the fix, and build, test and swiftlint are green on the PR head with no warning in the changed files. @@ -173,7 +177,7 @@ BUG-06, wiring (D-02). Start from the pushed branch head as in Task 1. | Boundary | Description | |----------|-------------| -| any local process → MenuBarItemService listener | The service answers source-PID lookups; it must only answer holzIce | +| any local process → MenuBarItemService listener | The service answers source-PID lookups; it must only answer holzBar | | app → MenuBarItemService session | The app trusts the PIDs the service returns | | app binary → system frameworks at launch | A framework newer than the deployment target, linked into the app, stops it from launching | @@ -181,23 +185,23 @@ BUG-06, wiring (D-02). Start from the pushed branch head as in Task 1. | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| -| T-02-07 | Spoofing | MenuBarItemService/Listener.swift peer requirement on ad hoc builds | high | mitigate | Require `SigningIdentifier("com.holzcloud.holzIce")` and `CodeDirectoryHash.in(hashes of the embedding app)`; team builds require same team plus identifier. The hash reading is proven against a running process in CI (Task 1) | +| T-02-07 | Spoofing | MenuBarItemService/Listener.swift peer requirement on ad hoc builds | high | mitigate | Require `SigningIdentifier("com.holzcloud.holzBar")` and `CodeDirectoryHash.in(hashes of the embedding app)`; team builds require same team plus identifier. The hash reading is proven against a running process in CI (Task 1). The identifier cannot drift from the built app unnoticed: the verify pins `appIdentifier` and `name`, and the build's "Check the identifiers" step proves the built app is `name` without `.MenuBarItemService` | | T-02-08 | Elevation of Privilege | Listener activation when the requirement cannot be built | high | mitigate | `peerRequirement()` throws (wrong host bundle, unreadable or invalid signature, no hashes) and the listener stays inactive: fail closed; the app's existing in-app lookup takes over | | T-02-09 | Spoofing | App session without a peer requirement on ad hoc builds | low | accept | launchd resolves an app's XPC service only inside that app's bundle, so no other process can answer; pinning the on-disk hash of the same bundle would add nothing against tampering with that bundle, which already controls the app | -| T-02-10 | Denial of Service | App launch on macOS 14.0 to 14.3 | high | mitigate | LightweightCodeRequirements (14.4+) is imported only in the service, which runs only on macOS 26; the verify fails if `Ice/` or `Shared/` import it | +| T-02-10 | Denial of Service | App launch on macOS 14.0 to 14.3 | high | mitigate | LightweightCodeRequirements (14.4+) is imported only in the service, which runs only on macOS 26; the verify fails if `holzBar/` or `Shared/` import it | | T-02-SC | Tampering | Package.swift / dependencies | low | mitigate | Only local targets (`SharedCodeSigning`, `SharedCodeSigningTests`) are added; system frameworks only, no package dependency, no npm/pip/cargo install | - Both task verify commands pass on the final head. -- `git grep -n 'LightweightCodeRequirements' -- Ice Shared MenuBarItemService` lists only `MenuBarItemService/Listener.swift`. -- `git diff origin/main -- .github Ice.xcodeproj` is empty (no project or workflow change). +- `git grep -n 'LightweightCodeRequirements' -- holzBar Shared MenuBarItemService` lists only `MenuBarItemService/Listener.swift`. +- `git diff origin/main -- .github holzBar.xcodeproj` is empty (no project or workflow change). - An ad hoc build is accepted by the XPC menu bar item service, and foreign processes are still rejected (BUG-06). - The hash reading behind the requirement is proven by Swift Testing on the CI runner. -- holzIce still links nothing newer than macOS 14.0 into the app; build, test and swiftlint are green on the PR head. +- holzBar still links nothing newer than macOS 14.0 into the app; build, test and swiftlint are green on the PR head. diff --git a/.planning/phases/02-bug-fixes/02-04-PLAN.md b/.planning/phases/02-bug-fixes/02-04-PLAN.md index cdfb720c..27d93381 100644 --- a/.planning/phases/02-bug-fixes/02-04-PLAN.md +++ b/.planning/phases/02-bug-fixes/02-04-PLAN.md @@ -5,10 +5,10 @@ type: execute wave: 4 depends_on: ["02-03"] files_modified: - - Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift - - Ice/MenuBar/MacOS27/Core/SystemItems27.swift - - Tests/IceMacOS27CoreTests/SystemItems27Tests.swift - - Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift + - holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift + - holzBar/MenuBar/MacOS27/Core/SystemItems27.swift + - Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift + - holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift autonomous: true requirements: [BUG-04, BUG-08] @@ -25,26 +25,26 @@ must_haves: - "The allowlist range lives in the pure Core file SystemItems27.swift and is covered by Swift Testing in CI" - "The phase PR lists BUG-01 to BUG-08 and the open human checks, and build, test and swiftlint are green on its final head" artifacts: - - path: Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift + - path: holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift provides: "Lock-guarded event source cache" contains: "OSAllocatedUnfairLock(uncheckedState:" - - path: Ice/MenuBar/MacOS27/Core/SystemItems27.swift + - path: holzBar/MenuBar/MacOS27/Core/SystemItems27.swift provides: "Measured system item numbers and the allowlist derived from them" contains: "enum SystemItems27" - - path: Tests/IceMacOS27CoreTests/SystemItems27Tests.swift + - path: Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift provides: "Swift Testing suite for SystemItems27" contains: "@Suite(\"SystemItems27\")" - - path: Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift + - path: holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift provides: "Assertion configuration built from SystemItems27.allowed" contains: "SystemItems27.allowed" key_links: - - from: Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift - to: Ice/MenuBar/MacOS27/Core/SystemItems27.swift + - from: holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift + to: holzBar/MenuBar/MacOS27/Core/SystemItems27.swift via: "systemItems is built from SystemItems27.allowed" pattern: "SystemItems27\\.allowed" - - from: Tests/IceMacOS27CoreTests/SystemItems27Tests.swift - to: Ice/MenuBar/MacOS27/Core/SystemItems27.swift - via: "the IceMacOS27Core package target compiles the Core file the tests pin" + - from: Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift + to: holzBar/MenuBar/MacOS27/Core/SystemItems27.swift + via: "the HolzBarMacOS27Core package target compiles the Core file the tests pin" pattern: "SystemItems27\\.(allowed|drawn|highestMeasured)" --- @@ -66,7 +66,7 @@ Output: a lock-guarded cache, `SystemItems27.swift` with its tests, the assertio @CLAUDE.md @.planning/codebase/CONVENTIONS.md @.planning/codebase/TESTING.md -@Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift +@holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift @Scripts/macos27/system-item-probe.swift @.planning/phases/02-bug-fixes/02-01-SUMMARY.md @.planning/phases/02-bug-fixes/02-02-SUMMARY.md @@ -80,7 +80,7 @@ User decisions (orchestrator, 2026-10-02). Cite them by ID: - D-05: One PR for the whole phase, from `claude/ice-fork-development-hzdl1d` to `main`. Push with `git push origin HEAD:claude/ice-fork-development-hzdl1d` (never force). The orchestrator marks the PR ready and merges it. Everything in the repository is English. Evidence for the allowlist range, gathered while planning (2026-10-02). The decision below rests on it; do not re-derive it: -- `MenuBarAssessmentAssertion27.swift` (doc comment above `systemItems`): on macOS 27.0 only 0 (battery), 2 (clock), 6 (Wi-Fi) and 8 (Control Centre) draw anything; "All of them are accepted, though, up to 127 at least"; the reason for a wide range is that "a system item added by a later build would otherwise be concealed, and Ice hides applications' items, not the system's"; the capture-indicator paragraph says the assertion was tried with "every number to 127". +- `MenuBarAssessmentAssertion27.swift` (doc comment above `systemItems`): on macOS 27.0 only 0 (battery), 2 (clock), 6 (Wi-Fi) and 8 (Control Centre) draw anything; "All of them are accepted, though, up to 127 at least"; the reason for a wide range is that "a system item added by a later build would otherwise be concealed, and holzBar hides applications' items, not the system's" (Phase 01.1 changed "Ice" to "holzBar" there); the capture-indicator paragraph says the assertion was tried with "every number to 127". - `Scripts/macos27/system-item-probe.swift` header: measured on macOS 27.0 (2026-09-29) "with every number from 0 to 127 offered, one at a time"; "MenuBarAgent accepts them all and draws five"; the capture-indicator test held a live assertion with "all 128 numbers". - Git history of the line: a0c27a7 allowed 0...8; d1858fb widened it to 0...31 ("MenuBarAgent accepts far higher numbers without complaint", "all 128 numbers tried"); 15f59a9 set the current upper bound of 63 only to match the range of jordanbaird/Ice#1001 so the two branches would not collide when merged ("Nothing answers to either, so the difference is only a difference"). - `docs/macos27.md` says nothing about the numbers. `.planning/codebase/CONCERNS.md` (Scaling Limits): a system item numbered above 63 in a later macOS 27.x would be concealed; widen to the measured 127. @@ -88,21 +88,24 @@ Evidence for the allowlist range, gathered while planning (2026-10-02). The deci - Decision (D-03): allow 0 through 127. It is the widest range that was measured to work, including all at once in one live assertion; the comment's own reason (never conceal a system item a later build adds) argues for it; the narrower 63 was chosen only to avoid a merge conflict with an upstream PR, which is no technical reason. Rated reversible: one constant. Other facts. Do not re-derive them: -- `getEventSource(with:)` is at about line 731 of the 2047-line `Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift`; read that region only (Grep for `getEventSource`). It is `private nonisolated`, keeps a function-local `enum Context` whose static dictionary maps `CGEventSourceStateID` to `CGEventSource`, and throws `EventError.invalidEventSource` when `CGEventSource(stateID:)` fails. Callers: `permitLocalEvents()` (combinedSessionState) and two move/click paths (default hidSystemState). The file imports Cocoa, Combine, OSLog and Semaphore; files that use `OSAllocatedUnfairLock` import `os` or `os.lock`. `OSAllocatedUnfairLock(uncheckedState:)` and `withLockUnchecked` (macOS 13+) take state that is not Sendable, which `CGEventSource` is not. +- `getEventSource(with:)` is at line 732 of the 2047-line `holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift`; read that region only (Grep for `getEventSource`). It is `private nonisolated`, keeps a function-local `enum Context` whose static dictionary maps `CGEventSourceStateID` to `CGEventSource`, and throws `EventError.invalidEventSource` when `CGEventSource(stateID:)` fails. Callers: `permitLocalEvents()` (combinedSessionState) and two move/click paths (default hidSystemState). The file imports Cocoa, Combine, OSLog and Semaphore; files that use `OSAllocatedUnfairLock` import `os` or `os.lock`. `OSAllocatedUnfairLock(uncheckedState:)` and `withLockUnchecked` (macOS 13+) take state that is not Sendable, which `CGEventSource` is not. - `MenuBarItemManager` imports AppKit, so it cannot compile in the test package; BUG-04 is verified by the build, the code check and review (D-04). -- Files in `Ice/MenuBar/MacOS27/Core/` import only Foundation/CoreGraphics, carry no `@available`, use a `27` suffix, and are compiled both into the app and into the `IceMacOS27Core` package target; tests live in `Tests/IceMacOS27CoreTests/` as `27Tests.swift` with `@testable import IceMacOS27Core`. -- No Mac, no compiler here; CI (`build`, `test`, `swiftlint`) on the phase PR is the only build. REST only for `gh`. Waiting on CI: foreground `sleep` is blocked; poll with a background until-loop or the Monitor tool every 30 s, for at most 30 minutes, until the three check runs of the PR head have `status == "completed"`; on failure read the job log, fix, push, wait again. Log formats: `✔ Test "Display name" passed after ...`, `✔ Suite "Name" passed after ...`; warnings as `path/File.swift:L:C: warning: ...`. MenuBarItemManager.swift and MenuBarAssessmentAssertion27.swift have no warning today. -- SwiftLint (`Ice/` only, `--strict`): file header with the file name and `Ice`, mandatory trailing commas, caseless enums for static-only types, `///` docs on types and members. +- Files in `holzBar/MenuBar/MacOS27/Core/` import only Foundation/CoreGraphics, carry no `@available`, use a `27` suffix, and are compiled both into the app and into the `HolzBarMacOS27Core` package target; tests live in `Tests/HolzBarMacOS27CoreTests/` as `27Tests.swift` with `@testable import HolzBarMacOS27Core`. +- No Mac, no compiler here; CI (`build`, `test`, `swiftlint`) on the phase PR is the only build. REST only for `gh`. Waiting on CI: foreground `sleep` is blocked; poll with a background until-loop or the Monitor tool every 30 s, for at most 30 minutes, until the three check runs of the PR head have `status == "completed"`; on failure read the job log, fix, push, wait again. Log formats: `✔ Test "Display name" passed after ...`, `✔ Suite "Name" passed after ...`; warnings as `path/File.swift:L:C: warning: ...`. MenuBarItemManager.swift and MenuBarAssessmentAssertion27.swift have no warning today (main 16539c1). The `build` job ends with the step "Check the identifiers"; the `cask` workflow runs only when a PR touches `Casks/**`, `cask_renames.json` or `cask.yml`, which this phase does not, so the readback ignores it. +- The GitHub repository is now `holzcloud/holzBar`; through this session's proxy the old name `repos/holzcloud/holzIce` fails (its redirect goes to a numeric `repositories/` path the proxy refuses), so every `gh api` call uses `repos/holzcloud/holzBar`. git follows GitHub's redirect for the `origin` remote; if a push fails because of the old name, `git remote set-url origin https://github.com/holzcloud/holzBar` and push again. +- Names after the rename (Phase 01.1): source folder `holzBar/`, module `holzBar`, test package `HolzBarMacOS27Core` with tests in `Tests/HolzBarMacOS27CoreTests/`; the Ice Bar is the holzBar Shelf. `.planning/codebase/*.md` predate the rename: read their `Ice/` as `holzBar/` and `IceMacOS27Core` as `HolzBarMacOS27Core`. +- CLAUDE.md "Principles" bind every change. `OSAllocatedUnfairLock` is the current lock for the macOS 14 deployment target (`Mutex` needs macOS 15), and the doc comment states why the unchecked API is used. The allowlist only decides which of MenuBarAgent's own items stay visible; it adds no permission and no network call. +- SwiftLint (`holzBar/` only, `--strict`): file header with the file name and `holzBar`, mandatory trailing commas, caseless enums for static-only types, `///` docs on types and members. CI readback used by both verifies (`$D` holds the three job logs afterwards): -`git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log"` +`git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log"` Task 1: the event source cache is guarded by a lock - Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift + holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift BUG-04. Start from the pushed branch head: `git fetch origin`, then rebase onto `origin/claude/ice-fork-development-hzdl1d` if HEAD does not contain it. @@ -114,8 +117,8 @@ BUG-04. Start from the pushed branch head: `git fetch origin`, then rebase onto 6. Commit (for example `fix(02-04): guard the event source cache with a lock`), push, wait for CI and fix until build, test and swiftlint are green with no warning in MenuBarItemManager.swift. - ! grep -n 'static var cache' Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift && grep -q 'OSAllocatedUnfairLock(uncheckedState: \[CGEventSourceStateID: CGEventSource\]())' Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift && grep -q 'Context.sources.withLockUnchecked' Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift && grep -qE '^import os(\.lock)?$' Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift && test "$(grep -c 'try getEventSource' Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift)" -ge 3 && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && ! grep -E 'Ice/MenuBar/MenuBarItems/MenuBarItemManager\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "event source cache green on $H" - On the Mac (macOS 26.7.1): in Settings, Menu Bar Layout, drag several items between sections in quick succession, and click hidden items in the holzIce Bar while a move is still running. Items move and open as before; holzIce does not crash. + ! grep -n 'static var cache' holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift && grep -q 'OSAllocatedUnfairLock(uncheckedState: \[CGEventSourceStateID: CGEventSource\]())' holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift && grep -q 'Context.sources.withLockUnchecked' holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift && grep -qE '^import os(\.lock)?$' holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift && test "$(grep -c 'try getEventSource' holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift)" -ge 3 && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && ! grep -E 'holzBar/MenuBar/MenuBarItems/MenuBarItemManager\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && echo "event source cache green on $H" + On the Mac (macOS 26.7.1): in Settings, Menu Bar Layout, drag several items between sections in quick succession, and click hidden items in the holzBar Shelf while a move is still running. Items move and open as before; holzBar does not crash. getEventSource(with:) reads and fills its cache only inside one OSAllocatedUnfairLock; no unguarded static cache remains; its callers are unchanged. build, test and swiftlint are green on the PR head with no warning in MenuBarItemManager.swift. @@ -123,7 +126,7 @@ BUG-04. Start from the pushed branch head: `git fetch origin`, then rebase onto Task 2: the allowlist is the measured range, said once in the comment and once in the code, and the phase PR is complete One constant in a Core file; narrowing or widening it later touches no stored data and no contract. - Ice/MenuBar/MacOS27/Core/SystemItems27.swift (new), Tests/IceMacOS27CoreTests/SystemItems27Tests.swift (new), Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift + holzBar/MenuBar/MacOS27/Core/SystemItems27.swift (new), Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift (new), holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift - Every drawn number (0, 2, 6, 8) is in the allowlist - The allowlist is exactly 0...127: it starts at 0 and ends at highestMeasured, which is 127 @@ -132,18 +135,18 @@ BUG-04. Start from the pushed branch head: `git fetch origin`, then rebase onto BUG-08 (D-03, D-04). Start from the pushed branch head as in Task 1. -1. Create `Ice/MenuBar/MacOS27/Core/SystemItems27.swift` (SwiftLint header, `import Foundation`, no `@available`): a caseless `enum SystemItems27`, documented as MenuBarAgent's numbered system items on macOS 27, measured with `Scripts/macos27/system-item-probe.swift` on macOS 27.0 (2026-09-29), with: +1. Create `holzBar/MenuBar/MacOS27/Core/SystemItems27.swift` (SwiftLint header, `import Foundation`, no `@available`): a caseless `enum SystemItems27`, documented as MenuBarAgent's numbered system items on macOS 27, measured with `Scripts/macos27/system-item-probe.swift` on macOS 27.0 (2026-09-29), with: - `static let drawn: Set = [0, 2, 6, 8]`, documented: the numbers that draw an item on macOS 27.0 (battery, clock, Wi-Fi, Control Centre). - `static let highestMeasured = 127`, documented: MenuBarAgent accepted every number from 0 to this one, offered one at a time and all together in one live assertion. - - `static let allowed: ClosedRange = 0...highestMeasured`, documented: the numbers holzIce keeps on the bar, all measured ones, so a system item that a later build numbers above 8 stays visible; holzIce hides applications' items, not the system's. -2. Create `Tests/IceMacOS27CoreTests/SystemItems27Tests.swift` (`import Testing`, `@testable import IceMacOS27Core`, `@Suite("SystemItems27")`) with one `@Test` per behavior above; name the first exactly `@Test("Every drawn system item is allowed")`. -3. `MenuBarAssessmentAssertion27.swift`: build `systemItems` from `SystemItems27.allowed` (each number as an `NSNumber`, as an `NSArray`, as today) instead of the current literal range with the upper bound of 63. Rewrite the first paragraph of its doc comment so it says what the code does, in this order: MenuBarAgent numbers its system items, and on macOS 27.0 only 0 (battery), 2 (clock), 6 (Wi-Fi) and 8 (Control Centre) draw anything; it accepted every number up to 127, one at a time and all at once; holzIce keeps that whole measured range (`SystemItems27`), so a system item added by a later build is not concealed, because holzIce hides applications' items, not the system's; jordanbaird/Ice#1001 (@carlossantos74) keeps 0 to 63, which lies inside it. Drop the sentence that claimed the two ranges are the same. Keep the capture-indicator paragraph and the measurement line as they are. + - `static let allowed: ClosedRange = 0...highestMeasured`, documented: the numbers holzBar keeps on the bar, all measured ones, so a system item that a later build numbers above 8 stays visible; holzBar hides applications' items, not the system's. +2. Create `Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift` (`import Testing`, `@testable import HolzBarMacOS27Core`, `@Suite("SystemItems27")`) with one `@Test` per behavior above; name the first exactly `@Test("Every drawn system item is allowed")`. +3. `MenuBarAssessmentAssertion27.swift`: build `systemItems` from `SystemItems27.allowed` (each number as an `NSNumber`, as an `NSArray`, as today) instead of the current literal range with the upper bound of 63. Rewrite the first paragraph of its doc comment so it says what the code does, in this order: MenuBarAgent numbers its system items, and on macOS 27.0 only 0 (battery), 2 (clock), 6 (Wi-Fi) and 8 (Control Centre) draw anything; it accepted every number up to 127, one at a time and all at once; holzBar keeps that whole measured range (`SystemItems27`), so a system item added by a later build is not concealed, because holzBar hides applications' items, not the system's; jordanbaird/Ice#1001 (@carlossantos74) keeps 0 to 63, which lies inside it. Drop the sentence that claimed the two ranges are the same. Keep the capture-indicator paragraph and the measurement line as they are. 4. Commit (for example `fix(02-04): allow the measured system item range on macOS 27`), push, wait for CI and fix until build, test and swiftlint are green with no warning in the changed files. Never disable, skip or delete a test (D-04). -5. Update the PR body through `gh api -X PATCH repos/holzcloud/holzIce/pulls/ -f body=...` (D-05): one paragraph saying Phase 2 is complete; one bullet per requirement BUG-01 to BUG-08 with what changed; a "Verified by" line (CI build, `swift test` with the new IceCore, SharedCodeSigning and SystemItems27 suites, strict SwiftLint); the human checks still open on a Mac, collected from the three earlier SUMMARYs and this plan; then the PR attribution lines from your session's system reminder. Leave the PR a draft: the orchestrator marks it ready and merges it. +5. Update the PR body through `gh api -X PATCH repos/holzcloud/holzBar/pulls/ -f body=...` (D-05): one paragraph saying Phase 2 is complete; one bullet per requirement BUG-01 to BUG-08 with what changed; a "Verified by" line (CI build, `swift test` with the new HolzBarCore, SharedCodeSigning and SystemItems27 suites, strict SwiftLint); the human checks still open on a Mac, collected from the three earlier SUMMARYs and this plan; then the PR attribution lines from your session's system reminder. Leave the PR a draft: the orchestrator marks it ready and merges it. - sed -n 2p Ice/MenuBar/MacOS27/Core/SystemItems27.swift | grep -qx '// SystemItems27.swift' && grep -q 'enum SystemItems27' Ice/MenuBar/MacOS27/Core/SystemItems27.swift && grep -qE 'static let drawn: Set = \[0, 2, 6, 8\]' Ice/MenuBar/MacOS27/Core/SystemItems27.swift && grep -qE 'static let highestMeasured = 127' Ice/MenuBar/MacOS27/Core/SystemItems27.swift && grep -qE 'static let allowed: ClosedRange = 0\.\.\.highestMeasured' Ice/MenuBar/MacOS27/Core/SystemItems27.swift && ! grep -nE '^import (AppKit|Cocoa|SwiftUI)' Ice/MenuBar/MacOS27/Core/SystemItems27.swift && test "$(grep -c '@Test' Tests/IceMacOS27CoreTests/SystemItems27Tests.swift)" -ge 3 && grep -q 'SystemItems27.allowed' Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && ! grep -F '(0...63)' Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && ! grep -n 'range matches the one' Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && grep -q '127' Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && grep -q 'jordanbaird/Ice#1001' Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzIce/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzIce/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzIce/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && grep -q 'Suite "SystemItems27" passed' "$D/test.log" && grep -q 'Test "Every drawn system item is allowed" passed' "$D/test.log" && grep -q 'Suite "SpacingRelaunch" passed' "$D/test.log" && grep -q 'Suite "Modifiers" passed' "$D/test.log" && grep -q 'Suite "CodeSignature" passed' "$D/test.log" && ! grep -E '(Ice/MenuBar/MacOS27/Core/SystemItems27|Ice/MenuBar/MacOS27/MenuBarAssessmentAssertion27)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && N=$(gh api "repos/holzcloud/holzIce/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].number') && B=$(gh api "repos/holzcloud/holzIce/pulls/$N" --jq '.body') && test -n "$B" && M=$(for r in BUG-01 BUG-02 BUG-03 BUG-04 BUG-05 BUG-06 BUG-07 BUG-08; do printf '%s' "$B" | grep -q "$r" || echo "$r"; done) && test -z "$M" && echo "phase 2 complete on $H (PR #$N)" - Later, on a Mac with macOS 27: hide a few apps' items with holzIce; battery, clock, Wi-Fi and Control Centre stay on the bar while the hidden apps' items disappear, as before. + sed -n 2p holzBar/MenuBar/MacOS27/Core/SystemItems27.swift | grep -qx '// SystemItems27.swift' && grep -q 'enum SystemItems27' holzBar/MenuBar/MacOS27/Core/SystemItems27.swift && grep -qE 'static let drawn: Set = \[0, 2, 6, 8\]' holzBar/MenuBar/MacOS27/Core/SystemItems27.swift && grep -qE 'static let highestMeasured = 127' holzBar/MenuBar/MacOS27/Core/SystemItems27.swift && grep -qE 'static let allowed: ClosedRange = 0\.\.\.highestMeasured' holzBar/MenuBar/MacOS27/Core/SystemItems27.swift && ! grep -nE '^import (AppKit|Cocoa|SwiftUI)' holzBar/MenuBar/MacOS27/Core/SystemItems27.swift && test "$(grep -c '@Test' Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift)" -ge 3 && grep -q 'SystemItems27.allowed' holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && ! grep -F '(0...63)' holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && ! grep -n 'range matches the one' holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && grep -q '127' holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && grep -q 'jordanbaird/Ice#1001' holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift && git fetch -q origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/claude/ice-fork-development-hzdl1d)" && H=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].head.sha') && test "$H" = "$(git rev-parse HEAD)" && gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq '[.check_runs[] | select(.name=="build" or .name=="test" or .name=="swiftlint")] | (map(.name)|unique|length)==3 and all(.conclusion=="success")' | grep -qx true && D=$(mktemp -d) && J() { gh api "repos/holzcloud/holzBar/commits/$H/check-runs?per_page=100" --jq "[.check_runs[] | select(.name==\"$1\")][0].id"; } && gh api "repos/holzcloud/holzBar/actions/jobs/$(J build)/logs" > "$D/build.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J test)/logs" > "$D/test.log" && gh api "repos/holzcloud/holzBar/actions/jobs/$(J swiftlint)/logs" > "$D/swiftlint.log" && grep -q 'BUILD SUCCEEDED' "$D/build.log" && grep -qE 'Test run with [0-9]+ tests in [0-9]+ suites passed' "$D/test.log" && grep -q 'Done linting! Found 0 violations' "$D/swiftlint.log" && grep -q 'Suite "SystemItems27" passed' "$D/test.log" && grep -q 'Test "Every drawn system item is allowed" passed' "$D/test.log" && grep -q 'Suite "SpacingRelaunch" passed' "$D/test.log" && grep -q 'Suite "Modifiers" passed' "$D/test.log" && grep -q 'Suite "CodeSignature" passed' "$D/test.log" && ! grep -E '(holzBar/MenuBar/MacOS27/Core/SystemItems27|holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27)\.swift:[0-9]+:[0-9]+: warning:' "$D/build.log" && N=$(gh api "repos/holzcloud/holzBar/pulls?head=holzcloud:claude/ice-fork-development-hzdl1d&state=open" --jq '.[0].number') && B=$(gh api "repos/holzcloud/holzBar/pulls/$N" --jq '.body') && test -n "$B" && M=$(for r in BUG-01 BUG-02 BUG-03 BUG-04 BUG-05 BUG-06 BUG-07 BUG-08; do printf '%s' "$B" | grep -q "$r" || echo "$r"; done) && test -z "$M" && echo "phase 2 complete on $H (PR #$N)" + Later, on a Mac with macOS 27: hide a few apps' items with holzBar; battery, clock, Wi-Fi and Control Centre stay on the bar while the hidden apps' items disappear, as before. SystemItems27.allowed (0...127) is the single source of the allowlist, built into the assertion configuration; the doc comment states the measured numbers, the measured accepted range, the reason for keeping it whole and how #1001's 0 to 63 relates, and no longer claims the ranges match. The SystemItems27 suite passes in CI along with the SpacingRelaunch, Modifiers and CodeSignature suites; build, test and swiftlint are green on the final head; the PR body covers BUG-01 to BUG-08 and the open human checks. @@ -156,7 +159,7 @@ BUG-08 (D-03, D-04). Start from the pushed branch head as in Task 1. | Boundary | Description | |----------|-------------| | concurrent move/click tasks → shared event source cache | Nonisolated code on several threads reads and writes one cache | -| holzIce → MenuBarAgent (private assessment-mode API) | The allowlist decides which system items stay visible while applications are concealed | +| holzBar → MenuBarAgent (private assessment-mode API) | The allowlist decides which system items stay visible while applications are concealed | ## STRIDE Threat Register @@ -164,7 +167,7 @@ BUG-08 (D-03, D-04). Start from the pushed branch head as in Task 1. |-----------|----------|-----------|----------|-------------|-----------------| | T-02-11 | Denial of Service | MenuBarItemManager.getEventSource cache (corrupted dictionary, crash) | medium | mitigate | One `OSAllocatedUnfairLock` around lookup, creation and store (`withLockUnchecked`); no unguarded static state remains (Task 1) | | T-02-12 | Information Disclosure | MenuBarAssessmentAssertion27 system item allowlist | low | accept | Widening keeps only MenuBarAgent's own system items visible; applications' items are still concealed by bundle identifier, so nothing a user hid becomes visible | -| T-02-SC | Tampering | Package.swift / dependencies | low | mitigate | No package change and no dependency; the new Core file joins the existing local `IceMacOS27Core` target | +| T-02-SC | Tampering | Package.swift / dependencies | low | mitigate | No package change and no dependency; the new Core file joins the existing local `HolzBarMacOS27Core` target | From 232fbc57e36c2fd0b8147a0cce0ddf4ec02837b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:44:30 +0000 Subject: [PATCH 02/24] fix(02-01): relaunch every app when applying spacing - Add the pure, tested SpacingRelaunch in holzBar/Core: every owner except holzBar, Control Center and MenuBarAgent, distinct and sorted; a skipped owner no longer ends the loop (BUG-01) - Read the item owners through MenuBarItemProvider27 on macOS 27 and never quit MenuBarAgent (BUG-03) - Collect failures from the task group's results instead of mutating a captured array - Add the HolzBarCore and HolzBarCoreTests targets to the test-only package Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- Package.swift | 17 +++++- .../SpacingRelaunchTests.swift | 61 +++++++++++++++++++ holzBar/Core/SpacingRelaunch.swift | 61 +++++++++++++++++++ .../Spacing/MenuBarItemSpacingManager.swift | 46 ++++++++++---- 4 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 Tests/HolzBarCoreTests/SpacingRelaunchTests.swift create mode 100644 holzBar/Core/SpacingRelaunch.swift diff --git a/Package.swift b/Package.swift index 5befef69..8ac30e6c 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,9 @@ // swift-tools-version:6.0 import PackageDescription -// Test-only package. It compiles holzBar's pure macOS 27 logic so that logic can be -// unit tested with `swift test`. The same files are compiled into the holzBar app -// through the synchronized `holzBar` folder group. +// Test-only package. It compiles holzBar's pure logic, `holzBar/Core` (any macOS) and +// `holzBar/MenuBar/MacOS27/Core` (macOS 27), so it can be unit tested with `swift test`. +// The app compiles the same files through the synchronized `holzBar` folder group. let package = Package( name: "HolzBarMacOS27Core", platforms: [.macOS(.v14)], @@ -19,5 +19,16 @@ let package = Package( path: "Tests/HolzBarMacOS27CoreTests", swiftSettings: [.swiftLanguageMode(.v5)] ), + .target( + name: "HolzBarCore", + path: "holzBar/Core", + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget( + name: "HolzBarCoreTests", + dependencies: ["HolzBarCore"], + path: "Tests/HolzBarCoreTests", + swiftSettings: [.swiftLanguageMode(.v5)] + ), ] ) diff --git a/Tests/HolzBarCoreTests/SpacingRelaunchTests.swift b/Tests/HolzBarCoreTests/SpacingRelaunchTests.swift new file mode 100644 index 00000000..eff3c79d --- /dev/null +++ b/Tests/HolzBarCoreTests/SpacingRelaunchTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import HolzBarCore + +@Suite("SpacingRelaunch") +struct SpacingRelaunchTests { + typealias Owner = SpacingRelaunch.Owner + + @Test("A skipped owner does not stop the others") + func skippedOwnerDoesNotStopTheOthers() { + let owners = [ + Owner(pid: 10, bundleIdentifier: "com.apple.controlcenter"), + Owner(pid: 20, bundleIdentifier: "com.example.first"), + Owner(pid: 30, bundleIdentifier: "com.holzcloud.holzBar"), + Owner(pid: 40, bundleIdentifier: "com.example.second"), + ] + #expect(SpacingRelaunch.processesToRelaunch(owners: owners, ownPID: 30) == [20, 40]) + } + + @Test("holzBar itself is never relaunched") + func ownProcessIsNeverRelaunched() { + let owners = [ + Owner(pid: 30, bundleIdentifier: "com.example.renamed"), + Owner(pid: 31, bundleIdentifier: nil), + Owner(pid: 50, bundleIdentifier: "com.example.app"), + ] + #expect(SpacingRelaunch.processesToRelaunch(owners: owners, ownPID: 30) == [31, 50]) + #expect(SpacingRelaunch.processesToRelaunch(owners: owners, ownPID: 31) == [30, 50]) + } + + @Test("Control Center and MenuBarAgent are never relaunched") + func systemProcessesAreNeverRelaunched() { + let owners = [ + Owner(pid: 5, bundleIdentifier: "com.apple.MenuBarAgent"), + Owner(pid: 6, bundleIdentifier: "com.apple.controlcenter"), + Owner(pid: 7, bundleIdentifier: "com.apple.Spotlight"), + ] + #expect(SpacingRelaunch.processesToRelaunch(owners: owners, ownPID: 1) == [7]) + } + + @Test("Each process is returned once, sorted by pid") + func distinctAndSorted() { + let owners = [ + Owner(pid: 90, bundleIdentifier: "com.example.c"), + Owner(pid: 20, bundleIdentifier: "com.example.a"), + Owner(pid: 90, bundleIdentifier: "com.example.c"), + Owner(pid: 40, bundleIdentifier: "com.example.b"), + Owner(pid: 20, bundleIdentifier: "com.example.a"), + ] + #expect(SpacingRelaunch.processesToRelaunch(owners: owners, ownPID: 1) == [20, 40, 90]) + } + + @Test("An owner without a bundle identifier is still returned") + func ownerWithoutBundleIdentifier() { + let owners = [ + Owner(pid: 12, bundleIdentifier: nil), + Owner(pid: 11, bundleIdentifier: "com.example.app"), + ] + #expect(SpacingRelaunch.processesToRelaunch(owners: owners, ownPID: 1) == [11, 12]) + } +} diff --git a/holzBar/Core/SpacingRelaunch.swift b/holzBar/Core/SpacingRelaunch.swift new file mode 100644 index 00000000..56f8f14e --- /dev/null +++ b/holzBar/Core/SpacingRelaunch.swift @@ -0,0 +1,61 @@ +// +// SpacingRelaunch.swift +// holzBar +// + +import Foundation + +/// The pure decisions of the menu bar item spacing relaunch. +/// +/// Applying a spacing offset only takes effect in apps that are started again, so holzBar +/// quits and reopens every process that owns a menu bar item. This type decides which of +/// those processes are relaunched; `MenuBarItemSpacingManager` does the quitting and +/// launching. +enum SpacingRelaunch { + /// A process that owns at least one menu bar item. + struct Owner: Hashable { + /// The process identifier of the owner. + let pid: pid_t + /// The bundle identifier of the owner, if it has one. + let bundleIdentifier: String? + } + + /// The bundle identifier of Control Center. + /// + /// Control Center relaunches itself once told to quit, so the manager asks it once, + /// after the other apps. + static let controlCenterBundleIdentifier = "com.apple.controlcenter" + + /// The bundle identifier of MenuBarAgent. + /// + /// On macOS 27 MenuBarAgent hosts the system items (the same identifier as + /// `MenuBarItemProvider27.menuBarAgentBundleID`). It is a system agent, never quit and + /// reopened by holzBar. + static let menuBarAgentBundleIdentifier = "com.apple.MenuBarAgent" + + /// The processes to quit and reopen, sorted by process identifier. + /// + /// Every distinct process of `owners` is returned except holzBar itself (`ownPID`), + /// Control Center and MenuBarAgent. A skipped owner never stops the others from being + /// relaunched. + /// + /// - Parameters: + /// - owners: The processes that own the menu bar items. + /// - ownPID: The process identifier of holzBar. + static func processesToRelaunch(owners: [Owner], ownPID: pid_t) -> [pid_t] { + let skippedBundleIdentifiers: Set = [ + controlCenterBundleIdentifier, + menuBarAgentBundleIdentifier, + ] + let pids = owners.compactMap { owner -> pid_t? in + guard owner.pid != ownPID else { + return nil + } + if let bundleIdentifier = owner.bundleIdentifier, skippedBundleIdentifiers.contains(bundleIdentifier) { + return nil + } + return owner.pid + } + return Set(pids).sorted() + } +} diff --git a/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift b/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift index cdf6de31..36aefa1c 100644 --- a/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift +++ b/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift @@ -158,26 +158,41 @@ final class MenuBarItemSpacingManager { try? await Task.sleep(for: .milliseconds(100)) - let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) - let pids = Set(items.map { $0.sourcePID ?? $0.ownerPID }) + let items: [MenuBarItem] + if #available(macOS 27.0, *) { + // macOS 27 has no item windows. Accessibility names the owning process of + // every item, concealed ones included. + items = await MenuBarItemProvider27.items() + } else { + items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + } + + let owners = Set(items.map { $0.sourcePID ?? $0.ownerPID }).map { pid in + SpacingRelaunch.Owner( + pid: pid, + bundleIdentifier: NSRunningApplication(processIdentifier: pid)?.bundleIdentifier + ) + } + let pids = SpacingRelaunch.processesToRelaunch( + owners: owners, + ownPID: ProcessInfo.processInfo.processIdentifier + ) var failedApps = [String]() - await withTaskGroup(of: Void.self) { group in + await withTaskGroup(of: String?.self) { group in for pid in pids { - guard - let app = NSRunningApplication(processIdentifier: pid), - app.bundleIdentifier != "com.apple.controlcenter", // ControlCenter handles its own relaunch, so skip it. - app != .current - else { - break + guard let app = NSRunningApplication(processIdentifier: pid) else { + // The process is gone, so there is nothing to relaunch. + continue } group.addTask { @MainActor in do { try await self.relaunchApp(app) + return nil } catch { guard let name = app.localizedName else { - return + return nil } if app.bundleIdentifier == "com.apple.Spotlight" { // Spotlight automatically relaunches, so only consider it a failure if it never quit. @@ -185,14 +200,19 @@ final class MenuBarItemSpacingManager { let latestSpotlightInstance = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Spotlight").first, latestSpotlightInstance.processIdentifier == app.processIdentifier { - failedApps.append(name) + return name } - } else { - failedApps.append(name) + return nil } + return name } } } + for await name in group { + if let name { + failedApps.append(name) + } + } } try? await Task.sleep(for: .milliseconds(100)) From fd2ada5b38a9a49e8e948c30d66d1cf896d10791 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:50:07 +0000 Subject: [PATCH 03/24] test(02-01): add failing tests for the quit wait and its timeout - waitUntil returns true when the event happens, false at the timeout and false at once when cancelled; quitTimeout is 10 seconds - SpacingRelaunch gets stubs so the tests compile and fail on their assertions Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .../SpacingRelaunchTests.swift | 65 +++++++++++++++++++ holzBar/Core/SpacingRelaunch.swift | 8 +++ 2 files changed, 73 insertions(+) diff --git a/Tests/HolzBarCoreTests/SpacingRelaunchTests.swift b/Tests/HolzBarCoreTests/SpacingRelaunchTests.swift index eff3c79d..05ae6953 100644 --- a/Tests/HolzBarCoreTests/SpacingRelaunchTests.swift +++ b/Tests/HolzBarCoreTests/SpacingRelaunchTests.swift @@ -58,4 +58,69 @@ struct SpacingRelaunchTests { ] #expect(SpacingRelaunch.processesToRelaunch(owners: owners, ownPID: 1) == [11, 12]) } + + @Test("Waiting returns at once when the event has already happened") + func eventAlreadyHappened() async { + let clock = ContinuousClock() + let start = clock.now + let happened = await SpacingRelaunch.waitUntil(timeout: .seconds(5)) {} + #expect(happened) + #expect(clock.now - start < .seconds(4)) + } + + @Test("Waiting returns as soon as the event happens") + func eventHappensLater() async { + let (stream, continuation) = AsyncStream.makeStream(of: Void.self) + let yielder = Task { + try? await Task.sleep(for: .milliseconds(20)) + continuation.yield() + continuation.finish() + } + let clock = ContinuousClock() + let start = clock.now + let happened = await SpacingRelaunch.waitUntil(timeout: .seconds(30)) { + for await _ in stream { + return + } + } + await yielder.value + #expect(happened) + #expect(clock.now - start < .seconds(5)) + } + + @Test("Waiting gives up at the timeout") + func waitingGivesUpAtTheTimeout() async { + let (stream, continuation) = AsyncStream.makeStream(of: Void.self) + let happened = await SpacingRelaunch.waitUntil(timeout: .milliseconds(50)) { + for await _ in stream { + return + } + } + continuation.finish() + #expect(!happened) + } + + @Test("A cancelled wait returns at once") + func cancelledWaitReturnsAtOnce() async { + let (stream, continuation) = AsyncStream.makeStream(of: Void.self) + let clock = ContinuousClock() + let start = clock.now + let wait = Task { + await SpacingRelaunch.waitUntil(timeout: .seconds(30)) { + for await _ in stream { + return + } + } + } + wait.cancel() + let happened = await wait.value + continuation.finish() + #expect(!happened) + #expect(clock.now - start < .seconds(5)) + } + + @Test("An app gets 10 seconds to quit") + func quitTimeoutIsTenSeconds() { + #expect(SpacingRelaunch.quitTimeout == .seconds(10)) + } } diff --git a/holzBar/Core/SpacingRelaunch.swift b/holzBar/Core/SpacingRelaunch.swift index 56f8f14e..079697e5 100644 --- a/holzBar/Core/SpacingRelaunch.swift +++ b/holzBar/Core/SpacingRelaunch.swift @@ -58,4 +58,12 @@ enum SpacingRelaunch { } return Set(pids).sorted() } + + /// How long an app gets to quit after being asked. + static let quitTimeout: Duration = .seconds(1) + + /// Waits for `event` for at most `timeout`. + static func waitUntil(timeout: Duration, _ event: @escaping @Sendable () async -> Void) async -> Bool { + false + } } From 6a03218beaa4f00d1175559621f80f0d995b5df9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:53:41 +0000 Subject: [PATCH 04/24] docs: insert phase 06.1 compatibility check Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .planning/REQUIREMENTS.md | 13 +++++++++++-- .planning/ROADMAP.md | 15 +++++++++++++++ .../phases/06.1-compatibility-check/.gitkeep | 1 + .planning/state.json | 4 ++-- 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 .planning/phases/06.1-compatibility-check/.gitkeep diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index be718eaa..bf00a5ab 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -86,6 +86,12 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). - [ ] **PERF-02**: Permission polling stops once all permissions are granted - [ ] **PERF-03**: Reveal rules react to power and network notifications instead of polling every 60 s +### Compatibility + +- [ ] **COMPAT-01**: macOS 26 (Tahoe) and macOS 27 are supported without restriction; this is mandatory +- [ ] **COMPAT-02**: For macOS 14 and 15 it is measured (CI builds and tests on macos-14 and macos-15 runners, `#available` branches reviewed) what works and what it costs to keep; the user decides whether to keep them or raise the deployment target to macOS 26 (which removes the pre-26 backend code) +- [ ] **COMPAT-03**: README, the cask's `depends_on macos:`, the badge and the release notes state exactly the supported versions + ### Release - [ ] **REL-01**: `0.0.6-beta1` is released with hand-written notes (brew trust, update, quarantine) and the cask points at it @@ -172,11 +178,14 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). | PRIV-02 | Phase 05.1 | Pending | | PERM-01 | Phase 05.1 | Pending | | AUDIT-01 | Phase 6 | Pending | +| COMPAT-01 | Phase 06.1 | Pending | +| COMPAT-02 | Phase 06.1 | Pending | +| COMPAT-03 | Phase 06.1 | Pending | | REL-01 | Phase 7 | Pending | **Coverage:** -- v1 requirements: 60 total -- Mapped to phases: 60 +- v1 requirements: 63 total +- Mapped to phases: 63 - Unmapped: 0 ✓ --- diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 0b5752d2..739ef50d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -20,6 +20,7 @@ Decimal phases appear between their surrounding integers in numeric order. - [ ] **Phase 5: Security and performance** - Validated settings import, private logging, Caches storage, no needless tasks or polling - [ ] **Phase 05.1: Modern, lean and private** (INSERTED) - 2026 code, Swift 6, @Observable, fewer dependencies, no network, least privilege - [ ] **Phase 6: Security audit** - Full security analysis of the whole app, findings ranked, fixes chosen by the user done before the release +- [ ] **Phase 06.1: Compatibility check** (INSERTED) - Which macOS versions really work; 26 and 27 required, older ones optional - [ ] **Phase 7: Release 0.0.6-beta1** - Tag, hand-written release notes, cask updated Each phase is one pull request and must build green on the macOS CI runner before merge. @@ -161,6 +162,20 @@ Plans: **Plans**: TBD +### Phase 06.1: Compatibility check (INSERTED) + +**Goal:** It is known and documented which macOS versions holzBar really runs on; macOS 26 and 27 are guaranteed, older versions are kept only where they cost little +**Requirements**: COMPAT-01, COMPAT-02, COMPAT-03 +**Depends on:** Phase 6 +**Success Criteria** (what must be TRUE): + 1. CI builds and runs the unit tests on every macOS runner GitHub offers (macos-14, macos-15, macos-26), and the deployment target matches the oldest version that really works + 2. macOS 26 and 27 are verified on real Macs by the user with a short checklist (hiding, Shelf, layout editor, hotkeys, settings import) + 3. README, cask `depends_on macos:` and release notes state the supported versions truthfully; if the user decides to drop 14/15, the old backend code is removed (lean) +**Plans:** 0 plans + +Plans: +- [ ] TBD (run /gsd-plan-phase 06.1 to break down) + ### Phase 7: Release 0.0.6-beta1 **Goal**: Users can install and update to `0.0.6-beta1` through Homebrew with clear instructions diff --git a/.planning/phases/06.1-compatibility-check/.gitkeep b/.planning/phases/06.1-compatibility-check/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/.planning/phases/06.1-compatibility-check/.gitkeep @@ -0,0 +1 @@ + diff --git a/.planning/state.json b/.planning/state.json index 47ea4a28..841a66dd 100644 --- a/.planning/state.json +++ b/.planning/state.json @@ -47,7 +47,7 @@ "next": { "command": "/gsd:progress --next", "label": "Advance to the next step (verify)", - "reason": "Phase 1 of 8 · ready to verify" + "reason": "Phase 1 of 9 · ready to verify" }, - "updated_at": "2026-10-02T12:43:02.536Z" + "updated_at": "2026-10-02T14:53:41.751Z" } From 652b1e6b93e91256898378a0fa3a28a514b5e278 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:55:13 +0000 Subject: [PATCH 05/24] fix(02-01): wait for apps to quit instead of killing them - SpacingRelaunch.waitUntil races the event against the timeout in a task group: true when the event happens, false at the timeout or at once when cancelled; it always returns and polls nothing - quitTimeout is 10 seconds; an app still running then is left alone and named in the alert, never force terminated (BUG-02) - quit(_:) observes isTerminated through key-value observation and waits through waitUntil; the Combine sink, the checked continuation and the force-termination fallback are gone Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- holzBar/Core/SpacingRelaunch.swift | 30 +++++- .../Spacing/MenuBarItemSpacingManager.swift | 93 ++++++++++--------- 2 files changed, 75 insertions(+), 48 deletions(-) diff --git a/holzBar/Core/SpacingRelaunch.swift b/holzBar/Core/SpacingRelaunch.swift index 079697e5..af73e9e8 100644 --- a/holzBar/Core/SpacingRelaunch.swift +++ b/holzBar/Core/SpacingRelaunch.swift @@ -60,10 +60,36 @@ enum SpacingRelaunch { } /// How long an app gets to quit after being asked. - static let quitTimeout: Duration = .seconds(1) + /// + /// Long enough for an app that saves or syncs on quit. An app that is still running + /// then is left alone and reported. + static let quitTimeout: Duration = .seconds(10) /// Waits for `event` for at most `timeout`. + /// + /// Runs `event` and a sleep of `timeout` side by side, takes whichever finishes first and + /// cancels the other. It always returns: with no continuation to leak and nothing polled. + /// `event` must return when its task is cancelled (iterating an `AsyncStream` does), + /// because the wait finishes only once both of them have. + /// + /// - Parameters: + /// - timeout: How long to wait for the event. + /// - event: Returns once the event has happened. + /// - Returns: `true` as soon as the event happens, `false` once the timeout has passed + /// and `false` at once when the waiting task is cancelled. static func waitUntil(timeout: Duration, _ event: @escaping @Sendable () async -> Void) async -> Bool { - false + await withTaskGroup(of: Bool.self) { group in + group.addTask { + await event() + return !Task.isCancelled + } + group.addTask { + try? await Task.sleep(for: timeout) + return false + } + let happened = await group.next() ?? false + group.cancelAll() + return happened + } } } diff --git a/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift b/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift index 36aefa1c..f1f799a7 100644 --- a/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift +++ b/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift @@ -4,7 +4,6 @@ // import Cocoa -import Combine import OSLog /// Manager for menu bar item spacing. @@ -29,7 +28,9 @@ final class MenuBarItemSpacingManager { let failedApps: [String] var errorDescription: String? { - "The following applications failed to quit and were not restarted:\n" + failedApps.joined(separator: "\n") + let seconds = SpacingRelaunch.quitTimeout.components.seconds + return "The following applications did not quit within \(seconds) seconds and were not restarted:\n" + + failedApps.joined(separator: "\n") } var recoverySuggestion: String? { @@ -40,9 +41,6 @@ final class MenuBarItemSpacingManager { /// Logger for the menu bar item spacing manager. private let logger = Logger(category: "MenuBarItemSpacingManager") - /// Delay before force terminating an app. - private let forceTerminateDelay = 1 - /// The offset to apply to the default spacing and padding. /// Does not take effect until ``applyOffset()`` is called. var offset = 0 @@ -72,45 +70,52 @@ final class MenuBarItemSpacingManager { try await runCommand("defaults", with: ["-currentHost", "write", "-globalDomain", key.rawValue, "-int", String(key.defaultValue + offset)]) } - /// Asynchronously signals the given app to quit. - private func signalAppToQuit(_ app: NSRunningApplication) async throws { + /// Asks the given app to quit and waits until it has, for at most + /// ``SpacingRelaunch/quitTimeout``. + /// + /// An app that is still running then is left alone; it is never force terminated. + /// + /// - Returns: Whether the app has quit. + private func quit(_ app: NSRunningApplication) async -> Bool { if app.isTerminated { logger.debug("Application \"\(app.logString, privacy: .public)\" is already terminated") - return - } else { - logger.debug("Signaling application \"\(app.logString, privacy: .public)\" to quit") + return true } + // React to the app's termination instead of checking it on a timer. The initial + // value covers an app that quits before the observation starts. + let (terminated, continuation) = AsyncStream.makeStream(of: Void.self) + let observation = app.observe(\.isTerminated, options: [.initial, .new]) { @Sendable _, change in + if change.newValue == true { + continuation.yield() + continuation.finish() + } + } + + logger.debug("Signaling application \"\(app.logString, privacy: .public)\" to quit") app.terminate() - var cancellable: AnyCancellable? - return try await withCheckedThrowingContinuation { continuation in - let timeoutTask = Task { - try await Task.sleep(for: .seconds(forceTerminateDelay)) - if !app.isTerminated { - logger.debug( - """ - Application \"\(app.logString, privacy: .public)\" did not terminate within \ - \(self.forceTerminateDelay, privacy: .public) seconds, attempting to force terminate - """ - ) - app.forceTerminate() - } + let didQuit = await SpacingRelaunch.waitUntil(timeout: SpacingRelaunch.quitTimeout) { + for await _ in terminated { + return } + } - cancellable = app.publisher(for: \.isTerminated).sink { [weak self] isTerminated in - guard - let self, - isTerminated - else { - return - } - timeoutTask.cancel() - cancellable?.cancel() - logger.debug("Application \"\(app.logString, privacy: .public)\" terminated successfully") - continuation.resume() - } + observation.invalidate() + continuation.finish() + + if didQuit || app.isTerminated { + logger.debug("Application \"\(app.logString, privacy: .public)\" terminated successfully") + return true } + let seconds = SpacingRelaunch.quitTimeout.components.seconds + logger.debug( + """ + Application \"\(app.logString, privacy: .public)\" did not quit within \ + \(seconds, privacy: .public) seconds, so it is left running + """ + ) + return false } /// Asynchronously launches the app at the given URL. @@ -136,12 +141,10 @@ final class MenuBarItemSpacingManager { else { throw RelaunchError() } - try await signalAppToQuit(app) - if app.isTerminated { - try await launchApp(at: url, bundleIdentifier: bundleIdentifier) - } else { + guard await quit(app) else { throw RelaunchError() } + try await launchApp(at: url, bundleIdentifier: bundleIdentifier) } /// Applies the current ``offset``. @@ -217,13 +220,11 @@ final class MenuBarItemSpacingManager { try? await Task.sleep(for: .milliseconds(100)) - if let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.controlcenter").first { - do { - try await signalAppToQuit(app) - } catch { - if let name = app.localizedName { - failedApps.append(name) - } + // Control Center relaunches itself once told to quit. + if let app = NSRunningApplication.runningApplications(withBundleIdentifier: SpacingRelaunch.controlCenterBundleIdentifier).first { + let didQuit = await quit(app) + if !didQuit, let name = app.localizedName { + failedApps.append(name) } } From 1edeab1e99ccafd6eddda4cad53a0ee0907f8180 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:00:57 +0000 Subject: [PATCH 06/24] docs(02-01): complete the spacing relaunch plan - SUMMARY with PR #35, CI results (124 tests) and the open human checks - BUG-01, BUG-02 and BUG-03 marked complete; roadmap and state updated Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .planning/REQUIREMENTS.md | 12 +- .planning/ROADMAP.md | 7 +- .planning/STATE.md | 17 +-- .../phases/02-bug-fixes/02-01-SUMMARY.md | 104 ++++++++++++++++++ 4 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 .planning/phases/02-bug-fixes/02-01-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index bf00a5ab..cf692fbb 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -30,9 +30,9 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). ### Bugs -- [ ] **BUG-01**: Applying menu bar item spacing relaunches every affected app (`continue` instead of `break`) -- [ ] **BUG-02**: Spacing relaunch waits long enough for apps to quit, does not force-terminate them after 1 s, and always resumes its continuation -- [ ] **BUG-03**: Spacing relaunch collects the owning apps on macOS 27 too +- [x] **BUG-01**: Applying menu bar item spacing relaunches every affected app (`continue` instead of `break`) +- [x] **BUG-02**: Spacing relaunch waits long enough for apps to quit, does not force-terminate them after 1 s, and always resumes its continuation +- [x] **BUG-03**: Spacing relaunch collects the owning apps on macOS 27 too - [ ] **BUG-04**: The event source cache in `MenuBarItemManager` is free of data races - [ ] **BUG-05**: The hotkey recorder rejects combinations that macOS 15+ cannot register (Option or Option+Shift only) and tells the user; the hotkey signature stays identical to Ice's - [ ] **BUG-06**: The XPC menu bar item service accepts the app on ad hoc builds (no team identifier) while still rejecting foreign processes @@ -134,9 +134,9 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). | REN-06 | Phase 01.1 | Complete | | REN-07 | Phase 01.1 | Complete | | REN-08 | Phase 01.1 | Complete | -| BUG-01 | Phase 2 | Pending | -| BUG-02 | Phase 2 | Pending | -| BUG-03 | Phase 2 | Pending | +| BUG-01 | Phase 2 | Complete | +| BUG-02 | Phase 2 | Complete | +| BUG-03 | Phase 2 | Complete | | BUG-04 | Phase 2 | Pending | | BUG-05 | Phase 2 | Pending | | BUG-06 | Phase 2 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 739ef50d..026b5028 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -80,10 +80,10 @@ Plans: 4. Waiting for a permission twice never hangs, and the event source cache has no data race 5. The macOS 27 system item allowlist comment and code agree -**Plans**: 4 plans (sequential waves: one PR branch, every task verified by its CI checks) +**Plans**: 1/4 plans executed (sequential waves: one PR branch, every task verified by its CI checks) Plans: -- [ ] 02-01-PLAN.md — Tracer: tested `holzBar/Core` package target (`HolzBarCore`); spacing relaunch keeps going past skipped processes (MenuBarAgent skipped on macOS 27), 10 s event-driven quit wait, no force-termination; phase PR opened +- [x] 02-01-PLAN.md — Tracer: tested `holzBar/Core` package target (`HolzBarCore`); spacing relaunch keeps going past skipped processes (MenuBarAgent skipped on macOS 27), 10 s event-driven quit wait, no force-termination; phase PR opened - [ ] 02-02-PLAN.md — Hotkey recorder refuses Option-only combinations on macOS 15+ and says why (signature unchanged); every permission wait returns - [ ] 02-03-PLAN.md — XPC service accepts holzBar's ad hoc build by pinning the embedding app's signing identifier and code directory hashes (proven by a CodeSignature test suite); foreign processes still rejected - [ ] 02-04-PLAN.md — Lock-guarded event source cache; macOS 27 system item allowlist 0 to 127 with matching comment and tests; PR body complete @@ -171,6 +171,7 @@ Plans: 1. CI builds and runs the unit tests on every macOS runner GitHub offers (macos-14, macos-15, macos-26), and the deployment target matches the oldest version that really works 2. macOS 26 and 27 are verified on real Macs by the user with a short checklist (hiding, Shelf, layout editor, hotkeys, settings import) 3. README, cask `depends_on macos:` and release notes state the supported versions truthfully; if the user decides to drop 14/15, the old backend code is removed (lean) + **Plans:** 0 plans Plans: @@ -197,7 +198,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 |-------|----------------|--------|-----------| | 1. CI and build | 3/3 | Complete (human check: install.sh on a Mac) | 2026-10-02 | | 01.1. Rename to holzBar | 6/6 | In Progress| | -| 2. Bug fixes | 0/4 | Planned | - | +| 2. Bug fixes | 1/4 | In Progress| | | 3. Ice and Sparkle leftovers | 0/0 | Not started | - | | 4. Outdated APIs | 0/0 | Not started | - | | 5. Security and performance | 0/0 | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index ff9b75ff..35cb7f58 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,16 +4,16 @@ milestone: v0.0.6 current_phase: 1 current_phase_name: CI and build status: verifying -stopped_at: Completed 01.1-06-PLAN.md (repository rename by the user pending) -last_updated: "2026-10-02T14:16:00.237Z" +stopped_at: Completed 02-01-PLAN.md +last_updated: "2026-10-02T15:00:45.330Z" last_activity: 2026-10-02 last_activity_desc: Roadmap created (6 phases, 37 requirements mapped) -state_head: d6f029dd4b1360b625a6b5c50a0c9ca9ec3b066a +state_head: 652b1e6b93e91256898378a0fa3a28a514b5e278 progress: - total_phases: 9 + total_phases: 10 completed_phases: 0 total_plans: 13 - completed_plans: 9 + completed_plans: 10 --- # Project State @@ -64,6 +64,7 @@ Progress: [░░░░░░░░░░] 0% | Phase 01.1 P03 | 11min | 2 tasks | 69 files | | Phase 01.1 P04 | 20min | 2 tasks | 15 files | | Phase 01.1 P06 | 20min | 2 tasks | 10 files | +| Phase 02 P01 | 16min | 2 tasks | 4 files | ## Accumulated Context @@ -90,6 +91,8 @@ Recent decisions affecting current work: - [Phase 01.1]: holzIce users must trust holzcloud/holzice/holzbar once before brew update: Homebrew 7.0.7 does not carry the holzice trust over to the renamed cask (measured in the cask job) - [Phase 01.1]: Interim cask holzbar installs holzIce 0.0.5 until the first holzBar release; release.yml switches url, app, postflight and uninstall to holzBar - [Phase 01.1]: No conflicts_with cask holzice and no tap_migrations.json: the rename mapping would make holzbar conflict with itself, and both tap names are this repository +- [Phase 02]: Spacing relaunch skips only holzBar, Control Center and MenuBarAgent (SpacingRelaunch.processesToRelaunch) +- [Phase 02]: Apps get 10 s (SpacingRelaunch.quitTimeout) to quit and are never force terminated; the wait is KVO-driven and always returns ### Pending Todos @@ -109,6 +112,6 @@ Items acknowledged and deferred at milestone close, most recent first: ## Session Continuity -Last session: 2026-10-02T14:16:00.190Z -Stopped at: Completed 01.1-06-PLAN.md (repository rename by the user pending) +Last session: 2026-10-02T15:00:45.280Z +Stopped at: Completed 02-01-PLAN.md Resume file: None diff --git a/.planning/phases/02-bug-fixes/02-01-SUMMARY.md b/.planning/phases/02-bug-fixes/02-01-SUMMARY.md new file mode 100644 index 00000000..ce8d1e42 --- /dev/null +++ b/.planning/phases/02-bug-fixes/02-01-SUMMARY.md @@ -0,0 +1,104 @@ +--- +phase: 02-bug-fixes +plan: 01 +subsystem: menu-bar-spacing +status: complete +tags: [bug-fix, spacing, relaunch, swift-concurrency, kvo, swift-testing, macos27] +requires: [] +provides: + - "holzBar/Core: pure, unit-tested folder compiled into the app and into the test-only package (HolzBarCore / HolzBarCoreTests)" + - "SpacingRelaunch.processesToRelaunch, quitTimeout and waitUntil" + - "Draft phase PR #35 (claude/ice-fork-development-hzdl1d -> main)" +affects: + - "Every later Phase 2 plan adds its tests to Package.swift next to HolzBarCoreTests and lands in PR #35" +tech-stack: + added: [] + patterns: + - "Event-or-timeout wait as a two-child task group (no continuation, no polling)" + - "Key-value observation of NSRunningApplication.isTerminated feeding an AsyncStream" + - "Task group children return their result instead of mutating a captured array" +key-files: + created: + - holzBar/Core/SpacingRelaunch.swift + - Tests/HolzBarCoreTests/SpacingRelaunchTests.swift + modified: + - Package.swift + - holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift +decisions: + - "Spacing relaunch: skip only holzBar itself, Control Center and MenuBarAgent; every other owner is relaunched, sorted by pid" + - "Apps get SpacingRelaunch.quitTimeout (10 s) to quit and are never force terminated; one still running is left alone and named in the alert" + - "The KVO change handler is @Sendable and reads change.newValue, so it touches neither the app nor the main actor" +metrics: + duration: 16min + completed: 2026-10-02 + tasks: 2 + files: 4 +estimate: + tokens: 60000 + tasks: 2 +actuals: + tokens: 5000 + tasks: 2 + commits: 4 +plan_head_before: b4f4bd9a065e157bc962540d516b4639f881d47f +plan_head_after: 652b1e6b93e91256898378a0fa3a28a514b5e278 +--- + +# Phase 2 Plan 01: Spacing relaunch Summary + +Applying menu bar item spacing now relaunches every app that owns a menu bar item (the loop no longer stops at the first skipped process), waits up to 10 seconds for each to quit through key-value observation of `isTerminated` instead of killing it after 1 second, and on macOS 27 reads the owners through Accessibility and never quits MenuBarAgent. The decisions live in a new pure, unit-tested `holzBar/Core/SpacingRelaunch.swift`; the draft phase PR is open. + +## Phase PR + +- **PR #35**, draft: https://github.com/holzcloud/holzBar/pull/35 (head `claude/ice-fork-development-hzdl1d`, base `main`) +- Last CI head: `652b1e6b93e91256898378a0fa3a28a514b5e278`: `build`, `test` and `swiftlint` all success. +- Last `test` run: "Test run with 124 tests in 23 suites passed" (114 on main before this plan; +10 in the new `SpacingRelaunch` suite). +- Build: `** BUILD SUCCEEDED **`, 9 compiler warnings, none in `holzBar/Core` or `MenuBarItemSpacingManager.swift` (the 9 are the pre-existing ones, e.g. `HIDEventManager.swift`, `ItemClicker27.swift`). +- SwiftLint: "Done linting! Found 0 violations". + +## Tasks + +| Task | Name | Commit | Files | +| ---- | ---- | ------ | ----- | +| 1 (tracer) | A skipped process no longer stops the spacing relaunch | 232fbc5 | Package.swift, holzBar/Core/SpacingRelaunch.swift, Tests/HolzBarCoreTests/SpacingRelaunchTests.swift, MenuBarItemSpacingManager.swift | +| 2 RED | Failing tests for the quit wait and its timeout | fd2ada5 | SpacingRelaunch.swift (stubs), SpacingRelaunchTests.swift | +| 2 GREEN | Wait for apps to quit instead of killing them | 652b1e6 | SpacingRelaunch.swift, MenuBarItemSpacingManager.swift | + +Tracer gate: after Task 1, CI on 232fbc5 was green (119 tests, "A skipped owner does not stop the others" passed, no warning in the changed files), so the expansion (Task 2) went ahead. + +## What changed + +- **BUG-01**: `SpacingRelaunch.processesToRelaunch(owners:ownPID:)` returns every distinct owner except holzBar, Control Center and MenuBarAgent, sorted by pid. `applyOffset()` loops over that list and `continue`s past a process that is gone, so no owner is left out depending on a `Set`'s order. +- **BUG-02**: `quit(_:)` observes `isTerminated` (`.initial, .new`) into an `AsyncStream`, calls `terminate()` and waits through `SpacingRelaunch.waitUntil(timeout: SpacingRelaunch.quitTimeout)`. The wait is a two-child task group (event and sleep). It returns true at the event, false at the timeout, and false at once on cancellation, and it never leaks a continuation. The force-termination fallback, `forceTerminateDelay`, the Combine sink, the checked throwing continuation and `import Combine` are gone. The alert reads "did not quit within 10 seconds and were not restarted". The Control Center tail reports Control Center when it does not quit. +- **BUG-03**: on macOS 27 the owners come from `MenuBarItemProvider27.items()` (Accessibility names the owner of every item, concealed ones included); MenuBarAgent is never relaunched. +- Failures are collected from `withTaskGroup(of: String?.self)` results instead of being appended to a captured array from child tasks. +- `Package.swift` adds `HolzBarCore` (`holzBar/Core`) and `HolzBarCoreTests`; the existing macOS 27 targets are unchanged. + +## TDD Gate Compliance + +- RED: `test(02-01)` commit fd2ada5 added the five wait/timeout tests against stubs (`waitUntil` returning false, `quitTimeout` 1 s). CI `test` job 110889397319 failed on assertions: "Waiting returns as soon as the event happens", "Waiting returns at once when the event has already happened" (`Expectation failed: happened`) and "An app gets 10 seconds to quit" (`1.0 seconds == 10.0 seconds`). The other two passed against the stub, as expected (it returns false). Swift Testing does not print TAP, so I converted the job log's per-test results to TAP. `check tdd-red-evidence` on the converted results gave `RED_EVIDENCE_OK` (target "Waiting returns as soon as the event happens"). +- GREEN: `fix(02-01)` commit 652b1e6. All 10 `SpacingRelaunch` tests pass in CI. +- REFACTOR: not needed. + +## Deviations from Plan + +- **[Rule 3 - Blocking] Task 2 commit sequence.** Task 2 is `tdd="true"`, so I split it into a RED commit (tests plus compiling stubs, pushed so CI could show the assertion failures) and a GREEN commit, not one commit. So CI on the PR was red for one run (fd2ada5), as planned. +- **[Rule 1 - Correctness] KVO handler reads `change.newValue`.** The `isTerminated` change handler is marked `@Sendable` and reads `change.newValue` instead of the app, so it is safe whichever thread KVO calls it on and captures only the stream's continuation. +- **Plan verification note, not a code change:** `git diff origin/main -- .github holzBar.xcodeproj` is no longer empty. `main` has moved ahead since the branch point: PR #34 (cms-version) added `.github/cms-version.py` and changed `release.yml`. This plan changes neither; the three-dot diff `origin/main...HEAD -- .github holzBar.xcodeproj` is empty. The orchestrator will need to bring `main` into the branch (or merge through GitHub) before the phase PR is merged. +- `commits: 4` counts every commit in `b4f4bd9..652b1e6`. Three are this plan's; 6a03218 ("docs: insert phase 06.1 compatibility check") is the coordinator's planning commit, which I picked up by rebasing. + +## Open human checks + +- macOS 26.7.1: with several apps that have menu bar items running, change "Menu bar item spacing" and press Apply. Every app with a menu bar item quits and reopens, including when Control Center or holzBar would have come first. Repeat once: the same set relaunches. +- macOS 26.7.1: run an app that asks before quitting (leave the question unanswered) and apply spacing. The app is not killed. After about 10 s holzBar shows "did not quit within 10 seconds and were not restarted" naming it. The other apps relaunch. +- macOS 27: apply spacing. No alert names MenuBarAgent, and the apps with menu bar items relaunch. Note whether the spacing between items changes; if it does not, MenuBarAgent reads the setting itself, so open a follow-up. + +## Known Stubs + +None. The RED stubs were replaced in the GREEN commit. + +## Self-Check: PASSED + +- FOUND: holzBar/Core/SpacingRelaunch.swift, Tests/HolzBarCoreTests/SpacingRelaunchTests.swift +- FOUND commits: 232fbc5, fd2ada5, 652b1e6 +- Both task verify commands pass on 652b1e6 (CI readback green, static checks green). From 3b65dc3fb916cd210076f57c9de7753cbeb379bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:05:28 +0000 Subject: [PATCH 07/24] README: switching to the holzcloud/holzbar tap Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8ab6dd2a..d361f129 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,16 @@ brew trust --cask holzcloud/holzice/holzbar brew update && brew upgrade --cask holzbar ``` -Your tap `holzcloud/holzice` keeps working: Homebrew knows the cask was renamed and moves your install to `holzbar` during `brew update`. The new trust is needed because Homebrew trusts casks by name and only loads a renamed cask you have trusted. If you ran `brew update` before trusting it, run `brew migrate --cask holzice` once after `brew trust`. Until the first holzBar release there is nothing new to download; you keep holzIce 0.0.5 under the new name. +Your tap `holzcloud/holzice` keeps working: Homebrew knows the cask was renamed and moves your install to `holzbar` during `brew update`. The new trust is needed because Homebrew trusts casks by name and only loads a renamed cask you have trusted; trusting `holzcloud/holzice/holzice` does not help, and `brew install --cask holzice` asks you to trust `holzbar`. If you ran `brew update` before trusting it, run `brew migrate --cask holzice` once after `brew trust`. Until the first holzBar release there is nothing new to download; you keep holzIce 0.0.5 under the new name. + +To switch to the new tap name instead (recommended for a fresh install): + +```sh +brew untap --force holzcloud/holzice +brew tap holzcloud/holzbar https://github.com/holzcloud/holzBar +brew trust --cask holzcloud/holzbar/holzbar +brew install --cask holzbar +``` On its first launch, holzBar takes over holzIce's settings, layout profiles, item images and iCloud sync file, and offers to quit holzIce. Then: From 8eb7f927a2cb914b3592f1657ded0cca94157d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:07:25 +0000 Subject: [PATCH 08/24] test(02-02): add failing tests for the hotkey modifier rule - Move the Modifiers type, canonicalOrder and symbolicValue into the pure holzBar/Core/Modifiers.swift; the AppKit and Carbon conversions stay in holzBar/Hotkeys/ModifierFlags.swift - Add Modifiers.Rejection and a stub rejection(refusesOptionOnly:) - Add the Modifiers Swift Testing suite Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- Tests/HolzBarCoreTests/ModifiersTests.swift | 76 +++++++++++++++++++ holzBar/Core/Modifiers.swift | 67 ++++++++++++++++ .../{Modifiers.swift => ModifierFlags.swift} | 48 +++--------- 3 files changed, 155 insertions(+), 36 deletions(-) create mode 100644 Tests/HolzBarCoreTests/ModifiersTests.swift create mode 100644 holzBar/Core/Modifiers.swift rename holzBar/Hotkeys/{Modifiers.swift => ModifierFlags.swift} (69%) diff --git a/Tests/HolzBarCoreTests/ModifiersTests.swift b/Tests/HolzBarCoreTests/ModifiersTests.swift new file mode 100644 index 00000000..22741672 --- /dev/null +++ b/Tests/HolzBarCoreTests/ModifiersTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import HolzBarCore + +@Suite("Modifiers") +struct ModifiersTests { + @Test("No modifier is always refused") + func noModifierIsRefused() { + let modifiers: Modifiers = [] + #expect(modifiers.rejection(refusesOptionOnly: true) == .missing) + #expect(modifiers.rejection(refusesOptionOnly: false) == .missing) + } + + @Test("Shift alone is always refused") + func shiftAloneIsRefused() { + let modifiers: Modifiers = [.shift] + #expect(modifiers.rejection(refusesOptionOnly: true) == .shiftOnly) + #expect(modifiers.rejection(refusesOptionOnly: false) == .shiftOnly) + } + + @Test("Option alone is refused from macOS 15") + func optionAloneIsRefusedFromMacOS15() { + let optionOnly: [Modifiers] = [[.option], [.option, .shift]] + for modifiers in optionOnly { + #expect(modifiers.rejection(refusesOptionOnly: true) == .optionOnly) + #expect(modifiers.rejection(refusesOptionOnly: false) == nil) + } + } + + @Test("Option with Command or Control is accepted") + func optionWithCommandOrControlIsAccepted() { + let combinations: [Modifiers] = [ + [.option, .command], + [.option, .control], + [.option, .shift, .command], + [.control, .option, .shift], + ] + for modifiers in combinations { + #expect(modifiers.rejection(refusesOptionOnly: true) == nil) + #expect(modifiers.rejection(refusesOptionOnly: false) == nil) + } + } + + @Test("Command or Control combinations are accepted") + func commandOrControlCombinationsAreAccepted() { + let combinations: [Modifiers] = [ + [.command], + [.control], + [.command, .shift], + [.control, .shift], + [.control, .command], + ] + for modifiers in combinations { + #expect(modifiers.rejection(refusesOptionOnly: true) == nil) + #expect(modifiers.rejection(refusesOptionOnly: false) == nil) + } + } + + @Test("Raw values keep the stored hotkeys") + func rawValuesKeepTheStoredHotkeys() throws { + #expect(Modifiers.control.rawValue == 1) + #expect(Modifiers.option.rawValue == 2) + #expect(Modifiers.shift.rawValue == 4) + #expect(Modifiers.command.rawValue == 8) + + let modifiers: Modifiers = [.option, .command] + let data = try JSONEncoder().encode(modifiers) + #expect(try JSONDecoder().decode(Modifiers.self, from: data) == modifiers) + } + + @Test("Symbols follow the system order") + func symbolsFollowTheSystemOrder() { + let modifiers: Modifiers = [.command, .shift, .option, .control] + #expect(modifiers.symbolicValue == "⌃⌥⇧⌘") + } +} diff --git a/holzBar/Core/Modifiers.swift b/holzBar/Core/Modifiers.swift new file mode 100644 index 00000000..046fb4c0 --- /dev/null +++ b/holzBar/Core/Modifiers.swift @@ -0,0 +1,67 @@ +// +// Modifiers.swift +// holzBar +// + +import Foundation + +/// A bit mask containing the modifier keys for a hotkey. +/// +/// The raw values are stored with every hotkey (and read from Ice's settings when +/// they are imported), so they must never change. The conversions to and from the +/// system's modifier flags live in `holzBar/Hotkeys/ModifierFlags.swift`. +struct Modifiers: OptionSet, Codable, Hashable { + let rawValue: Int + + static let control = Modifiers(rawValue: 1 << 0) + static let option = Modifiers(rawValue: 1 << 1) + static let shift = Modifiers(rawValue: 1 << 2) + static let command = Modifiers(rawValue: 1 << 3) +} + +extension Modifiers { + /// All modifiers in the order displayed by the system, + /// according to Apple's style guide. + static let canonicalOrder = [control, option, shift, command] + + /// A symbolic string representation of the modifiers. + var symbolicValue: String { + var result = "" + if contains(.control) { + result.append("⌃") + } + if contains(.option) { + result.append("⌥") + } + if contains(.shift) { + result.append("⇧") + } + if contains(.command) { + result.append("⌘") + } + return result + } +} + +extension Modifiers { + /// A reason why a combination with these modifiers cannot be used as a hotkey. + enum Rejection: Equatable { + /// No modifier: the key alone would fire the hotkey on every press. + case missing + /// Shift alone: the hotkey would fire on every capital letter. + case shiftOnly + /// Option, or Option and Shift: macOS 15 and later refuse to register these + /// with `RegisterEventHotKey`, so that a global hotkey cannot read typed text. + case optionOnly + } + + /// Returns the reason why a combination with these modifiers cannot be used as + /// a hotkey, or `nil` when it can. + /// + /// - Parameter refusesOptionOnly: Whether the system refuses hotkeys whose only + /// modifiers are Option, or Option and Shift. Callers pass `true` on macOS 15 + /// and later. + func rejection(refusesOptionOnly: Bool) -> Rejection? { + nil + } +} diff --git a/holzBar/Hotkeys/Modifiers.swift b/holzBar/Hotkeys/ModifierFlags.swift similarity index 69% rename from holzBar/Hotkeys/Modifiers.swift rename to holzBar/Hotkeys/ModifierFlags.swift index e8f2e925..7caa2bac 100644 --- a/holzBar/Hotkeys/Modifiers.swift +++ b/holzBar/Hotkeys/ModifierFlags.swift @@ -1,44 +1,17 @@ // -// Modifiers.swift +// ModifierFlags.swift // holzBar // import Carbon.HIToolbox import Cocoa -/// A bit mask containing the modifier keys for a hotkey. -struct Modifiers: OptionSet, Codable, Hashable { - let rawValue: Int - - static let control = Modifiers(rawValue: 1 << 0) - static let option = Modifiers(rawValue: 1 << 1) - static let shift = Modifiers(rawValue: 1 << 2) - static let command = Modifiers(rawValue: 1 << 3) -} - +/// Conversions between ``Modifiers`` and the system's modifier flags (Cocoa, +/// CoreGraphics and Carbon). +/// +/// The type itself lives in `holzBar/Core/Modifiers.swift`, without AppKit or Carbon, +/// so that it can be unit tested. extension Modifiers { - /// All modifiers in the order displayed by the system, - /// according to Apple's style guide. - static let canonicalOrder = [control, option, shift, command] - - /// A symbolic string representation of the modifiers. - var symbolicValue: String { - var result = "" - if contains(.control) { - result.append("⌃") - } - if contains(.option) { - result.append("⌥") - } - if contains(.shift) { - result.append("⇧") - } - if contains(.command) { - result.append("⌘") - } - return result - } - /// Cocoa flags. var nsEventFlags: NSEvent.ModifierFlags { var result: NSEvent.ModifierFlags = [] @@ -93,7 +66,8 @@ extension Modifiers { return result } - init(nsEventFlags: NSEvent.ModifierFlags) { + /// Creates modifiers from Cocoa flags. + init(nsEventFlags:NSEvent.ModifierFlags) { self.init() if nsEventFlags.contains(.control) { insert(.control) @@ -109,7 +83,8 @@ extension Modifiers { } } - init(cgEventFlags: CGEventFlags) { + /// Creates modifiers from CoreGraphics flags. + init(cgEventFlags:CGEventFlags) { self.init() if cgEventFlags.contains(.maskControl) { insert(.control) @@ -125,7 +100,8 @@ extension Modifiers { } } - init(carbonFlags: Int) { + /// Creates modifiers from raw Carbon flags. + init(carbonFlags:Int) { self.init() if carbonFlags & controlKey == controlKey { insert(.control) From ab6d3fcad8da5c0f715f198fa248739a322d1e11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:11:44 +0000 Subject: [PATCH 09/24] fix(02-02): refuse Option-only hotkeys on macOS 15 and tell the user - Modifiers.rejection(refusesOptionOnly:) refuses no modifier, Shift alone and, on macOS 15 and later, Option or Option and Shift alone - The recorder shows an alert that says why and to add Command or Control, and keeps recording; the system-reserved alert gets a message - HotkeyRegistry logs the reason instead of calling RegisterEventHotKey for an Option-only combination; the hotkey signature is unchanged - Fix the colon spacing in the ModifierFlags initializers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- holzBar/Core/Modifiers.swift | 11 +++- holzBar/Hotkeys/HotkeyRegistry.swift | 5 ++ holzBar/Hotkeys/ModifierFlags.swift | 6 +-- holzBar/UI/Views/HotkeyRecorder.swift | 72 +++++++++++++++++++++++---- 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/holzBar/Core/Modifiers.swift b/holzBar/Core/Modifiers.swift index 046fb4c0..8344e40a 100644 --- a/holzBar/Core/Modifiers.swift +++ b/holzBar/Core/Modifiers.swift @@ -62,6 +62,15 @@ extension Modifiers { /// modifiers are Option, or Option and Shift. Callers pass `true` on macOS 15 /// and later. func rejection(refusesOptionOnly: Bool) -> Rejection? { - nil + switch self { + case []: + .missing + case .shift: + .shiftOnly + case .option, [.option, .shift]: + refusesOptionOnly ? .optionOnly : nil + default: + nil + } } } diff --git a/holzBar/Hotkeys/HotkeyRegistry.swift b/holzBar/Hotkeys/HotkeyRegistry.swift index e0594ef3..f540b6cb 100644 --- a/holzBar/Hotkeys/HotkeyRegistry.swift +++ b/holzBar/Hotkeys/HotkeyRegistry.swift @@ -136,6 +136,11 @@ final class HotkeyRegistry { return nil } + if #available(macOS 15.0, *), keyCombination.modifiers.rejection(refusesOptionOnly: true) == .optionOnly { + Logger.hotkeys.error("Hotkey not registered: macOS 15 and later do not register hotkeys whose only modifiers are Option, or Option and Shift") + return nil + } + var status = installIfNeeded() guard status == noErr else { diff --git a/holzBar/Hotkeys/ModifierFlags.swift b/holzBar/Hotkeys/ModifierFlags.swift index 7caa2bac..97e0b51a 100644 --- a/holzBar/Hotkeys/ModifierFlags.swift +++ b/holzBar/Hotkeys/ModifierFlags.swift @@ -67,7 +67,7 @@ extension Modifiers { } /// Creates modifiers from Cocoa flags. - init(nsEventFlags:NSEvent.ModifierFlags) { + init(nsEventFlags: NSEvent.ModifierFlags) { self.init() if nsEventFlags.contains(.control) { insert(.control) @@ -84,7 +84,7 @@ extension Modifiers { } /// Creates modifiers from CoreGraphics flags. - init(cgEventFlags:CGEventFlags) { + init(cgEventFlags: CGEventFlags) { self.init() if cgEventFlags.contains(.maskControl) { insert(.control) @@ -101,7 +101,7 @@ extension Modifiers { } /// Creates modifiers from raw Carbon flags. - init(carbonFlags:Int) { + init(carbonFlags: Int) { self.init() if carbonFlags & controlKey == controlKey { insert(.control) diff --git a/holzBar/UI/Views/HotkeyRecorder.swift b/holzBar/UI/Views/HotkeyRecorder.swift index a28a9fab..7828551e 100644 --- a/holzBar/UI/Views/HotkeyRecorder.swift +++ b/holzBar/UI/Views/HotkeyRecorder.swift @@ -25,12 +25,15 @@ struct HotkeyRecorder: View { label } .alert( - "Hotkey is reserved by macOS", - isPresented: $model.isPresentingSystemReservedError - ) { + model.presentedProblem?.title ?? "", + isPresented: $model.isPresentingProblem, + presenting: model.presentedProblem + ) { _ in Button("OK") { - model.isPresentingSystemReservedError = false + model.presentedProblem = nil } + } message: { problem in + Text(problem.message) } } @@ -124,7 +127,51 @@ private final class HotkeyRecorderModel: ObservableObject { @Published private(set) var isRecording = false - @Published var isPresentingSystemReservedError = false + /// A reason why the recorder refused the typed combination. + enum Problem { + /// macOS uses the combination for one of its own shortcuts. + case systemReserved + /// The only modifiers are Option, or Option and Shift, which macOS 15 and + /// later do not register. + case optionOnly + + /// The title of the alert that explains the problem. + var title: String { + switch self { + case .systemReserved: + "Hotkey is reserved by macOS" + case .optionOnly: + "macOS does not allow this hotkey" + } + } + + /// The message of the alert, which says what to do instead. + var message: String { + switch self { + case .systemReserved: + "macOS uses this combination for one of its own shortcuts. Choose another one." + case .optionOnly: + "Since macOS 15, a hotkey whose only modifiers are Option, or Option and Shift, cannot be registered. Add Command or Control." + } + } + } + + /// The problem the alert presents, if any. + @Published var presentedProblem: Problem? + + /// A Boolean value that indicates whether the alert for a problem is presented. + /// + /// Setting it to `false` clears the problem. + var isPresentingProblem: Bool { + get { + presentedProblem != nil + } + set { + if !newValue { + presentedProblem = nil + } + } + } let hotkey: Hotkey @@ -175,20 +222,27 @@ private final class HotkeyRecorderModel: ObservableObject { private func handleKeyDown(event: NSEvent) { let keyCombination = KeyCombination(event: event) - guard !keyCombination.modifiers.isEmpty else { + let refusesOptionOnly = if #available(macOS 15.0, *) { true } else { false } + switch keyCombination.modifiers.rejection(refusesOptionOnly: refusesOptionOnly) { + case .missing: if keyCombination.key == .escape { stopRecording() } else { NSSound.beep() } return - } - guard keyCombination.modifiers != .shift else { + case .shiftOnly: NSSound.beep() return + case .optionOnly: + // Keep recording, so that the user can type another combination. + presentedProblem = .optionOnly + return + case nil: + break } guard !keyCombination.isSystemReserved else { - isPresentingSystemReservedError = true + presentedProblem = .systemReserved return } hotkey.keyCombination = keyCombination From b66fe376939c4d2dca91cb8b118528b003182242 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:16:35 +0000 Subject: [PATCH 10/24] fix(02-02): every permission wait returns - waitForPermission() returns a Bool from its own AsyncStream, so any number of waits can run at once and each one ends - A grant ends every pending wait with true; stopCheck() ends them with false, and a cancelled task returns at once - The Grant buttons only reopen the permissions window on true - No stored single waiter and no checked continuation any more Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- holzBar/Permissions/Permission.swift | 60 ++++++++++++++++------- holzBar/Permissions/PermissionsView.swift | 8 ++- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/holzBar/Permissions/Permission.swift b/holzBar/Permissions/Permission.swift index af6dda94..c5873831 100644 --- a/holzBar/Permissions/Permission.swift +++ b/holzBar/Permissions/Permission.swift @@ -13,7 +13,13 @@ import Cocoa @MainActor class Permission: ObservableObject, Identifiable { /// A Boolean value that indicates whether the app has this permission. - @Published private(set) var hasPermission = false + @Published private(set) var hasPermission = false { + didSet { + if hasPermission, !waiters.isEmpty { + endWaits(with: true) + } + } + } /// The title of the permission. let title: String @@ -39,8 +45,8 @@ class Permission: ObservableObject, Identifiable { /// Observer that runs on a timer to check permissions. private var timerCancellable: AnyCancellable? - /// Observer that observes the ``hasPermission`` property. - private var hasPermissionCancellable: AnyCancellable? + /// The pending waits for this permission, each with its own stream. + private var waiters: [UUID: AsyncStream.Continuation] = [:] /// Creates a permission. /// @@ -126,23 +132,40 @@ class Permission: ObservableObject, Identifiable { } } - /// Asynchronously waits for the app to be granted this permission. - func waitForPermission() async { + /// Waits for the app to be granted this permission. + /// + /// Every call waits on its own stream, so any number of waits can run at the + /// same time and each one returns. + /// + /// - Returns: `true` once the app has the permission (at once when it already + /// has it), and `false` when ``stopCheck()`` ends the checks first or the + /// waiting task is cancelled. + @discardableResult + func waitForPermission() async -> Bool { configureCancellables() guard !hasPermission else { - return + return true } - return await withCheckedContinuation { continuation in - hasPermissionCancellable = $hasPermission.sink { [weak self] hasPermission in - guard let self else { - continuation.resume() - return - } - if hasPermission { - hasPermissionCancellable?.cancel() - continuation.resume() - } - } + let (stream, continuation) = AsyncStream.makeStream(of: Bool.self) + let id = UUID() + waiters[id] = continuation + defer { + waiters.removeValue(forKey: id) + } + for await granted in stream { + return granted + } + // Only reached when the waiting task is cancelled, which ends the iteration. + return hasPermission + } + + /// Ends every pending wait with the given result. + private func endWaits(with granted: Bool) { + let continuations = waiters.values + waiters.removeAll() + for continuation in continuations { + continuation.yield(granted) + continuation.finish() } } @@ -150,8 +173,7 @@ class Permission: ObservableObject, Identifiable { func stopCheck() { timerCancellable?.cancel() timerCancellable = nil - hasPermissionCancellable?.cancel() - hasPermissionCancellable = nil + endWaits(with: false) } } diff --git a/holzBar/Permissions/PermissionsView.swift b/holzBar/Permissions/PermissionsView.swift index 5b564fb7..85b7675f 100644 --- a/holzBar/Permissions/PermissionsView.swift +++ b/holzBar/Permissions/PermissionsView.swift @@ -152,7 +152,9 @@ struct PermissionsView: View { Button { permission.performRequest() Task { - await permission.waitForPermission() + guard await permission.waitForPermission() else { + return + } appState.activate(withPolicy: .regular) appState.openWindow(.permissions) } @@ -174,7 +176,9 @@ struct PermissionsView: View { Button("Reset and Grant Again") { permission.resetAndRequest() Task { - await permission.waitForPermission() + guard await permission.waitForPermission() else { + return + } appState.activate(withPolicy: .regular) appState.openWindow(.permissions) } From f0e7877b0527098ac849b02fd724ebc5a1d16524 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:22:48 +0000 Subject: [PATCH 11/24] docs(02-02): complete the Option-only hotkey and permission wait plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .planning/REQUIREMENTS.md | 8 +- .planning/ROADMAP.md | 6 +- .planning/STATE.md | 15 +- .../phases/02-bug-fixes/02-02-SUMMARY.md | 130 ++++++++++++++++++ 4 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 .planning/phases/02-bug-fixes/02-02-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index cf692fbb..57f16616 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -34,9 +34,9 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). - [x] **BUG-02**: Spacing relaunch waits long enough for apps to quit, does not force-terminate them after 1 s, and always resumes its continuation - [x] **BUG-03**: Spacing relaunch collects the owning apps on macOS 27 too - [ ] **BUG-04**: The event source cache in `MenuBarItemManager` is free of data races -- [ ] **BUG-05**: The hotkey recorder rejects combinations that macOS 15+ cannot register (Option or Option+Shift only) and tells the user; the hotkey signature stays identical to Ice's +- [x] **BUG-05**: The hotkey recorder rejects combinations that macOS 15+ cannot register (Option or Option+Shift only) and tells the user; the hotkey signature stays identical to Ice's - [ ] **BUG-06**: The XPC menu bar item service accepts the app on ad hoc builds (no team identifier) while still rejecting foreign processes -- [ ] **BUG-07**: Waiting for a permission twice never leaves a continuation unresumed +- [x] **BUG-07**: Waiting for a permission twice never leaves a continuation unresumed - [ ] **BUG-08**: Comment and code of the macOS 27 system item allowlist agree ### Leftovers @@ -138,9 +138,9 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). | BUG-02 | Phase 2 | Complete | | BUG-03 | Phase 2 | Complete | | BUG-04 | Phase 2 | Pending | -| BUG-05 | Phase 2 | Pending | +| BUG-05 | Phase 2 | Complete | | BUG-06 | Phase 2 | Pending | -| BUG-07 | Phase 2 | Pending | +| BUG-07 | Phase 2 | Complete | | BUG-08 | Phase 2 | Pending | | LEFT-01 | Phase 3 | Pending | | LEFT-02 | Phase 3 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 026b5028..cff2bdbf 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -80,11 +80,11 @@ Plans: 4. Waiting for a permission twice never hangs, and the event source cache has no data race 5. The macOS 27 system item allowlist comment and code agree -**Plans**: 1/4 plans executed (sequential waves: one PR branch, every task verified by its CI checks) +**Plans**: 2/4 plans executed (sequential waves: one PR branch, every task verified by its CI checks) Plans: - [x] 02-01-PLAN.md — Tracer: tested `holzBar/Core` package target (`HolzBarCore`); spacing relaunch keeps going past skipped processes (MenuBarAgent skipped on macOS 27), 10 s event-driven quit wait, no force-termination; phase PR opened -- [ ] 02-02-PLAN.md — Hotkey recorder refuses Option-only combinations on macOS 15+ and says why (signature unchanged); every permission wait returns +- [x] 02-02-PLAN.md — Hotkey recorder refuses Option-only combinations on macOS 15+ and says why (signature unchanged); every permission wait returns - [ ] 02-03-PLAN.md — XPC service accepts holzBar's ad hoc build by pinning the embedding app's signing identifier and code directory hashes (proven by a CodeSignature test suite); foreign processes still rejected - [ ] 02-04-PLAN.md — Lock-guarded event source cache; macOS 27 system item allowlist 0 to 127 with matching comment and tests; PR body complete @@ -198,7 +198,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 |-------|----------------|--------|-----------| | 1. CI and build | 3/3 | Complete (human check: install.sh on a Mac) | 2026-10-02 | | 01.1. Rename to holzBar | 6/6 | In Progress| | -| 2. Bug fixes | 1/4 | In Progress| | +| 2. Bug fixes | 2/4 | In Progress| | | 3. Ice and Sparkle leftovers | 0/0 | Not started | - | | 4. Outdated APIs | 0/0 | Not started | - | | 5. Security and performance | 0/0 | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 35cb7f58..6a06c868 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,16 +4,16 @@ milestone: v0.0.6 current_phase: 1 current_phase_name: CI and build status: verifying -stopped_at: Completed 02-01-PLAN.md -last_updated: "2026-10-02T15:00:45.330Z" +stopped_at: Completed 02-02-PLAN.md +last_updated: "2026-10-02T15:22:42.210Z" last_activity: 2026-10-02 last_activity_desc: Roadmap created (6 phases, 37 requirements mapped) -state_head: 652b1e6b93e91256898378a0fa3a28a514b5e278 +state_head: b66fe376939c4d2dca91cb8b118528b003182242 progress: total_phases: 10 completed_phases: 0 total_plans: 13 - completed_plans: 10 + completed_plans: 11 --- # Project State @@ -65,6 +65,7 @@ Progress: [░░░░░░░░░░] 0% | Phase 01.1 P04 | 20min | 2 tasks | 15 files | | Phase 01.1 P06 | 20min | 2 tasks | 10 files | | Phase 02 P01 | 16min | 2 tasks | 4 files | +| Phase 02 P02 | 15min | 2 tasks | 7 files | ## Accumulated Context @@ -93,6 +94,8 @@ Recent decisions affecting current work: - [Phase 01.1]: No conflicts_with cask holzice and no tap_migrations.json: the rename mapping would make holzbar conflict with itself, and both tap names are this repository - [Phase 02]: Spacing relaunch skips only holzBar, Control Center and MenuBarAgent (SpacingRelaunch.processesToRelaunch) - [Phase 02]: Apps get 10 s (SpacingRelaunch.quitTimeout) to quit and are never force terminated; the wait is KVO-driven and always returns +- [Phase 02]: Option-only and Option+Shift-only hotkeys are refused on macOS 15+ in the recorder (alert, recording continues) and in HotkeyRegistry (logged); the Carbon signature stays OSType(1231250720) (D-01) +- [Phase 02]: A permission wait returns false when stopCheck() ends it or its task is cancelled; the Grant buttons only reopen the permissions window on true ### Pending Todos @@ -112,6 +115,6 @@ Items acknowledged and deferred at milestone close, most recent first: ## Session Continuity -Last session: 2026-10-02T15:00:45.280Z -Stopped at: Completed 02-01-PLAN.md +Last session: 2026-10-02T15:22:42.160Z +Stopped at: Completed 02-02-PLAN.md Resume file: None diff --git a/.planning/phases/02-bug-fixes/02-02-SUMMARY.md b/.planning/phases/02-bug-fixes/02-02-SUMMARY.md new file mode 100644 index 00000000..4d1a896e --- /dev/null +++ b/.planning/phases/02-bug-fixes/02-02-SUMMARY.md @@ -0,0 +1,130 @@ +--- +phase: 02-bug-fixes +plan: 02 +subsystem: hotkeys-and-permissions +status: complete +tags: [bug-fix, hotkeys, macos15, permissions, swift-concurrency, asyncstream, swift-testing] +requires: + - "02-01: holzBar/Core with the HolzBarCore / HolzBarCoreTests targets, draft PR #35" +provides: + - "holzBar/Core/Modifiers.swift: the Modifiers option set (raw values 1, 2, 4, 8 unchanged) with Rejection and rejection(refusesOptionOnly:)" + - "holzBar/Hotkeys/ModifierFlags.swift: the Cocoa, CoreGraphics and Carbon conversions" + - "Permission.waitForPermission() -> Bool, safe to call any number of times" +affects: + - "MOD-02 (Phase 05.1) converts HotkeyRecorderModel and Permission to @Observable" + - "PERF-02 (Phase 5) stops the 1 s permission check once everything is granted" +tech-stack: + added: [] + patterns: + - "One AsyncStream per waiter, kept in a [UUID: Continuation] dictionary and ended by a single endWaits(with:)" + - "One @Published optional problem plus a computed isPresenting Bool driving alert(_:isPresented:presenting:actions:message:)" +key-files: + created: + - holzBar/Core/Modifiers.swift + - Tests/HolzBarCoreTests/ModifiersTests.swift + modified: + - holzBar/Hotkeys/ModifierFlags.swift (renamed from holzBar/Hotkeys/Modifiers.swift) + - holzBar/UI/Views/HotkeyRecorder.swift + - holzBar/Hotkeys/HotkeyRegistry.swift + - holzBar/Permissions/Permission.swift + - holzBar/Permissions/PermissionsView.swift +decisions: + - "Option-only and Option+Shift-only hotkeys are refused on macOS 15+ in the recorder (alert, recording continues) and in HotkeyRegistry (logged, RegisterEventHotKey not called); the Carbon signature OSType(1231250720) is unchanged (D-01)" + - "A permission wait returns false when stopCheck() ends it or its task is cancelled, and the Grant buttons only reopen the permissions window on true" +metrics: + duration: 15min + completed: 2026-10-02 + tasks: 2 + files: 7 +estimate: + tokens: 55000 + tasks: 2 +actuals: + tokens: 4400 + tasks: 2 + commits: 3 +plan_head_before: 3b65dc3fb916cd210076f57c9de7753cbeb379bc +plan_head_after: b66fe376939c4d2dca91cb8b118528b003182242 +--- + +# Phase 2 Plan 02: Option-only hotkeys and permission waits Summary + +On macOS 15 and later the hotkey recorder refuses a combination whose only modifiers are Option, or Option and Shift, explains in an alert that Command or Control is needed and keeps recording; `HotkeyRegistry` logs the reason instead of calling `RegisterEventHotKey` for it. The rule lives in the new pure, unit-tested `holzBar/Core/Modifiers.swift`. `Permission.waitForPermission()` now returns a Bool from its own `AsyncStream`, so any number of waits end (true on grant, false when the checks stop or the task is cancelled). + +## Phase PR + +- **PR #35**, draft: https://github.com/holzcloud/holzBar/pull/35 (head `claude/ice-fork-development-hzdl1d`) +- Final head `b66fe376939c4d2dca91cb8b118528b003182242`: `build`, `test` and `swiftlint` all success (both the push and the pull_request runs). +- `test`: "Test run with 131 tests in 24 suites passed" (124 in 23 suites before; +7 in the new `Modifiers` suite). +- `build`: `** BUILD SUCCEEDED **`; the only warnings are the pre-existing ones in `HIDEventManager.swift`, `ItemClicker27.swift` and `ScreenCapture.swift`, none in a file of this plan. +- `swiftlint`: "Done linting! Found 0 violations". + +## Tasks + +| Task | Name | Commit | Files | +| ---- | ---- | ------ | ----- | +| 1 RED | Failing tests for the hotkey modifier rule | 8eb7f92 | holzBar/Core/Modifiers.swift (stub), holzBar/Hotkeys/ModifierFlags.swift, Tests/HolzBarCoreTests/ModifiersTests.swift | +| 1 GREEN | Refuse Option-only hotkeys on macOS 15 and tell the user | ab6d3fc | holzBar/Core/Modifiers.swift, ModifierFlags.swift, HotkeyRecorder.swift, HotkeyRegistry.swift | +| 2 | Every permission wait returns | b66fe37 | Permission.swift, PermissionsView.swift | + +## What changed + +- **BUG-05** (D-01, recorder only): + - `Modifiers` (struct, conformances and raw values control 1, option 2, shift 4, command 8 unchanged), `canonicalOrder` and `symbolicValue` moved to `holzBar/Core/Modifiers.swift` (Foundation only). `holzBar/Hotkeys/Modifiers.swift` became `ModifierFlags.swift` (git rename) with only the `nsEventFlags`, `cgEventFlags`, `carbonFlags` conversions and their initializers. `KeyCombination`, its coding and `Migration.swift` are untouched, so stored and imported hotkeys decode as before. + - `Modifiers.Rejection` (`missing`, `shiftOnly`, `optionOnly`) and `rejection(refusesOptionOnly:)`. + - `HotkeyRecorderModel`: `presentedProblem: Problem?` (`systemReserved`, `optionOnly`, each with title and message) and a computed `isPresentingProblem`. `handleKeyDown` switches over the rejection: no modifier keeps the Escape/beep behavior, Shift alone beeps, Option-only shows "macOS does not allow this hotkey" and keeps recording, otherwise the system-reserved check (now with a message) and the store as before. The view uses `alert(_:isPresented:presenting:actions:message:)`. + - `HotkeyRegistry.register`: on macOS 15+ an Option-only combination is logged ("macOS 15 and later do not register hotkeys whose only modifiers are Option, or Option and Shift") and nil is returned. The signature line and the comment above it are byte for byte unchanged; `git diff origin/main` of the file touches no `OSType` line. +- **BUG-07**: the stored single waiter (`hasPermissionCancellable`) and the checked continuation are gone. `waiters: [UUID: AsyncStream.Continuation]`, `endWaits(with:)`, a `didSet` on `hasPermission` that ends all waits with true, and `stopCheck()` ending them with false. `waitForPermission()` is `@discardableResult async -> Bool`, removes its entry in a `defer` and returns `hasPermission` after a cancelled iteration. Both Grant buttons in `PermissionsView` use `guard await permission.waitForPermission() else { return }`. No Combine pipeline was added. + +## Tests that ran (CI, suite "Modifiers") + +- "No modifier is always refused" +- "Shift alone is always refused" +- "Option alone is refused from macOS 15" +- "Option with Command or Control is accepted" +- "Command or Control combinations are accepted" +- "Raw values keep the stored hotkeys" +- "Symbols follow the system order" + +`Permission` imports Cocoa and calls the Accessibility and screen capture checks, so Task 2 is covered by the CI build and the human check (D-04). + +## TDD Gate Compliance + +- RED: `test(02-02)` commit 8eb7f92 with a stub `rejection(refusesOptionOnly:)` returning nil. CI `test` job 110896387763 failed on assertions in "Option alone is refused from macOS 15" (`(modifiers.rejection(refusesOptionOnly: true) → nil) == .optionOnly`), "No modifier is always refused" and "Shift alone is always refused"; the other four passed against the stub, as expected. The Swift Testing per-test results were converted to TAP; `check tdd-red-evidence` gave `RED_EVIDENCE_OK` (target "Option alone is refused from macOS 15", 131 tests, 3 failing). +- GREEN: `fix(02-02)` commit ab6d3fc. All 7 `Modifiers` tests pass in CI. +- REFACTOR: not needed. + +## CI fixes needed + +1. RED run (8eb7f92), `swiftlint`: three "Colon Spacing Violation" errors in `ModifierFlags.swift` (`init(nsEventFlags:NSEvent.ModifierFlags)` etc.): the space after the colon was lost when the doc comments were added to the three initializers. Fixed in the GREEN commit ab6d3fc; `swiftlint` green from then on. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Lost space after the colon in the ModifierFlags initializers** +- **Found during:** Task 1 (RED CI run) +- **Issue:** adding `///` docs to the three initializers dropped the space after `nsEventFlags:`, `cgEventFlags:` and `carbonFlags:`; SwiftLint `--strict` failed. +- **Fix:** restored the spaces. +- **Files modified:** holzBar/Hotkeys/ModifierFlags.swift +- **Commit:** ab6d3fc + +Otherwise the plan was executed as written. The `///` docs on the three conversion initializers are an addition following the SwiftLint convention in the plan. + +## Open human checks + +- On the Mac (macOS 26.7.1), Settings, Hotkeys: Record Hotkey, Option-H shows "macOS does not allow this hotkey" saying Command or Control is needed, and after OK the recorder still records; Option-Shift-H shows the same alert; Command-Option-H records and fires from another app. +- Hotkeys set before the update still work. +- Permissions window with Screen Recording reset (`tccutil reset ScreenCapture com.holzcloud.holzBar`): "Grant Permission", then "Reset and Grant Again", then grant in System Settings: the permissions window comes to the front once and shows it as granted. Reset again, "Grant Permission", do not grant, click Continue: the window does not reopen by itself later. + +## Known Stubs + +None. + +## Threat Flags + +None. No new endpoint, permission, entitlement, network call or log line with personal data; T-02-04 and T-02-05 are mitigated as planned. + +## Self-Check: PASSED + +The three created files and the commits 8eb7f92, ab6d3fc and b66fe37 exist; both task verify commands pass on b66fe37. From 397e7e717dbc1790f75345be08a47fdbf4e4e962 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:35:34 +0000 Subject: [PATCH 12/24] chore(cask): point the cask only at holzBar - Drop cask_renames.json and the former-name app, quit and zap entries - The release workflow only updates version and sha256 - The cask workflow runs brew style and brew audit from the local tap Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .github/workflows/cask.yml | 95 ++++------------------------------- .github/workflows/release.yml | 13 +---- Casks/holzbar.rb | 20 ++------ cask_renames.json | 3 -- 4 files changed, 16 insertions(+), 115 deletions(-) delete mode 100644 cask_renames.json diff --git a/.github/workflows/cask.yml b/.github/workflows/cask.yml index 2ccae738..ecaa2946 100644 --- a/.github/workflows/cask.yml +++ b/.github/workflows/cask.yml @@ -1,14 +1,10 @@ -# Proves the path a holzIce user takes to holzBar: holzice 0.0.5 installed from the -# tap as it was before the rename, then `brew update && brew upgrade`. Homebrew must -# rename the installed cask to holzbar (cask_renames.json), upgrade it without a -# conflict and uninstall it, and the cask must pass `brew style` and `brew audit`. -# It installs a real app on a macOS runner, so it runs only when the cask changes. +# Checks the cask with `brew style` and `brew audit`, loaded from this repository as +# the tap. Nothing is downloaded or installed. It runs only when the cask changes. name: Cask on: pull_request: paths: - "Casks/**" - - "cask_renames.json" - ".github/workflows/cask.yml" permissions: @@ -20,15 +16,11 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - name: Migrate holzice to holzbar + - name: Style and audit the cask env: HOMEBREW_NO_AUTO_UPDATE: "1" HOMEBREW_NO_ENV_HINTS: "1" - # main before the rename: Casks/holzice.rb at 0.0.5 - OLD_COMMIT: 2b1b4f862993c38eb5452a22d0149a405816d844 run: | set -euo pipefail @@ -36,81 +28,12 @@ jobs: brew update --quiet brew --version - # A local copy of this repository serves as the tap, so its main can be - # moved from the pre-rename commit to this pull request's commit. - PR_COMMIT=$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD) + # A local copy of this pull request's commit on a main branch serves as the tap. SRC="$RUNNER_TEMP/tap-src" git clone --quiet "$GITHUB_WORKSPACE" "$SRC" + git -C "$SRC" checkout --quiet -B main "$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD)" - # A holzIce user as of before the rename: tapped, trusted holzice, installed it. - install_holzice() { - git -C "$SRC" checkout --quiet -B main "$OLD_COMMIT" - brew tap holzcloud/holzice "$SRC" - brew trust --cask holzcloud/holzice/holzice - brew install --cask holzcloud/holzice/holzice - test -d /Applications/holzIce.app - echo "==> installed holzice from the pre-rename tap" - # The rename lands on main; the next `brew update` fetches it. - git -C "$SRC" checkout --quiet -B main "$PR_COMMIT" - } - - migrated() { - grep -qx holzbar <<< "$(brew list --cask)" - } - - upgrade_and_uninstall() { - brew upgrade --cask holzbar - brew uninstall --cask holzbar - test ! -e /Applications/holzIce.app - test ! -e /Applications/holzBar.app - } - - # 1. Does the trust given to holzice carry over to holzbar? Homebrew loads a - # tap's casks only when they are trusted, and the migration loads holzbar. - install_holzice - brew update - if migrated; then - echo "==> trust carried over: yes" - else - echo "==> trust carried over: no" - # A user who ran `brew update` before trusting holzbar: trust it, then - # `brew migrate` does what the update would have done. - brew trust --cask holzcloud/holzice/holzbar - brew migrate --cask holzice - if migrated; then - echo "==> migrated after brew migrate: yes" - else - echo "==> migrated after brew migrate: no" - brew list --cask - exit 1 - fi - fi - upgrade_and_uninstall - brew untap holzcloud/holzice - # The migration leaves Caskroom/holzice as a link to Caskroom/holzbar. - CASKROOM=$(brew --caskroom) - if [ -L "$CASKROOM/holzice" ]; then - echo "Removing the leftover link $CASKROOM/holzice" - rm "$CASKROOM/holzice" - fi - - # 2. The README's path: trust holzbar, then `brew update && brew upgrade`. - install_holzice - brew trust --cask holzcloud/holzice/holzbar - brew update 2>&1 | tee "$RUNNER_TEMP/update.log" - if migrated; then - echo "==> migrated: yes" - else - echo "==> migrated: no" - echo "brew list --cask:" - brew list --cask - echo "brew update output:" - cat "$RUNNER_TEMP/update.log" - exit 1 - fi - - brew style --cask holzcloud/holzice/holzbar - brew audit --cask --strict holzcloud/holzice/holzbar - - upgrade_and_uninstall - echo "==> uninstalled: yes" + brew tap holzcloud/holzbar "$SRC" + brew trust --cask holzcloud/holzbar/holzbar + brew style --cask holzcloud/holzbar/holzbar + brew audit --cask --strict holzcloud/holzbar/holzbar diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0e5a8c0..251a710b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,9 +79,6 @@ jobs: gh release create "$TAG" "$ZIP" \ --target "$GITHUB_SHA" --title "holzBar $TAG" --notes-file "$NOTES" "${PRERELEASE[@]}" - # Until the first holzBar release the cask installs 0.0.5, the app under its former name. Every - # release points it at the holzBar zip and app just published; once that - # is done, these edits change nothing but the version and sha256. - name: Update the Homebrew cask run: | git fetch origin main @@ -89,20 +86,14 @@ jobs: sed -i '' -E \ -e "s/^ version \".*\"/ version \"$VERSION\"/" \ -e "s/^ sha256 .*/ sha256 \"$SHA256\"/" \ - -e '/^ # Until the first holzBar release/d' \ - -e 's|^ url ".*"$| url "https://github.com/holzcloud/holzBar/releases/download/v#{version}/holzBar-#{version}.zip"|' \ - -e 's|^ app ".*"$| app "holzBar.app"|' \ - -e 's|"[{][{]appdir[}][}]/[^"/]*[.]app"|"{{appdir}}/holzBar.app"|' \ - -e 's|writable_paths: [[]"[^"/]*[.]app"[]]|writable_paths: ["holzBar.app"]|' \ - -e 's|^ uninstall quit: .*| uninstall quit: "com.holzcloud.holzBar"|' \ Casks/holzbar.rb git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git commit -am "holzbar $VERSION" git push origin main - # holzcloud.ch shows the released version on the holzBar (formerly - # holzIce) page in five languages. This writes it there through the holzcloud-CMS once the + # holzcloud.ch shows the released version on the holzBar page in five + # languages. This writes it there through the holzcloud-CMS once the # release exists, and reads it back. A beta (0.0.6-beta1) is skipped: the # site keeps showing the last plain release. .github/cms-version.py is a # copy of holzcloud-design's tools/cms-version.py, where its tests live diff --git a/Casks/holzbar.rb b/Casks/holzbar.rb index 058b700e..9aa0f1ff 100644 --- a/Casks/holzbar.rb +++ b/Casks/holzbar.rb @@ -1,11 +1,9 @@ # Updated by .github/workflows/release.yml for every release. -# holzBar was called holzIce; cask_renames.json moves holzice installs here. cask "holzbar" do version "0.0.5" sha256 "ae6d4edf024babbfdbb0e0ceb954d68e62b662ffb8e6278fdd3c1315f68b515e" - # Until the first holzBar release this installs holzIce 0.0.5; the release workflow then switches it to holzBar. - url "https://github.com/holzcloud/holzIce/releases/download/v#{version}/holzIce-#{version}.zip" + url "https://github.com/holzcloud/holzBar/releases/download/v#{version}/holzBar-#{version}.zip" name "holzBar" desc "Menu bar manager forked from Ice" homepage "https://holzcloud.ch/holzbar" @@ -17,35 +15,27 @@ regex(/^v?(\d+(?:\.\d+)+(?:-[\w.]+)?)$/i) end - # No conflict with "holzice": Homebrew resolves that token through cask_renames.json - # to this cask, so holzbar would conflict with itself. The rename already keeps the - # two from being installed together, and the app offers to quit a running holzIce. conflicts_with cask: "jordanbaird-ice" depends_on macos: :sonoma - app "holzIce.app" + app "holzBar.app" # The app is signed ad hoc, not with a Developer ID, so Gatekeeper would # refuse to open it while it carries the quarantine attribute. postflight_steps do run "/usr/bin/xattr", - args: ["-dr", "com.apple.quarantine", "{{appdir}}/holzIce.app"], + args: ["-dr", "com.apple.quarantine", "{{appdir}}/holzBar.app"], must_succeed: false, - writable_paths: ["holzIce.app"], + writable_paths: ["holzBar.app"], writable_base: :appdir end - uninstall quit: ["com.holzcloud.holzBar", "com.holzcloud.holzIce"] + uninstall quit: "com.holzcloud.holzBar" - # The holzIce paths are what a user who came from holzIce leaves behind. zap trash: [ "~/Library/Application Support/holzBar", - "~/Library/Application Support/holzIce", "~/Library/Caches/com.holzcloud.holzBar", - "~/Library/Caches/com.holzcloud.holzIce", "~/Library/HTTPStorages/com.holzcloud.holzBar", - "~/Library/HTTPStorages/com.holzcloud.holzIce", "~/Library/Preferences/com.holzcloud.holzBar.plist", - "~/Library/Preferences/com.holzcloud.holzIce.plist", ] end diff --git a/cask_renames.json b/cask_renames.json deleted file mode 100644 index d2e517b5..00000000 --- a/cask_renames.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "holzice": "holzbar" -} From 7f00592a27397fbd15d780e0460d85910e3714b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:35:39 +0000 Subject: [PATCH 13/24] refactor(app): drop the former-name import, URL scheme and conflict entry - Import settings only from the original Ice - Accept only holzbar:// URLs (ice-bar stays as a shelf alias) - An unknown stored icon image set decodes as the default one Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- holzBar/Main/ConflictingApps.swift | 6 +- holzBar/Main/URLCommands.swift | 11 +- .../ControlItem/ControlItemImageSet.swift | 40 +++--- holzBar/Resources/Info.plist | 1 - holzBar/Utilities/Migration.swift | 114 ++++-------------- 5 files changed, 51 insertions(+), 121 deletions(-) diff --git a/holzBar/Main/ConflictingApps.swift b/holzBar/Main/ConflictingApps.swift index a508b27f..8f65aa5d 100644 --- a/holzBar/Main/ConflictingApps.swift +++ b/holzBar/Main/ConflictingApps.swift @@ -18,18 +18,14 @@ enum ConflictingApps { private static let logger = Logger(category: "ConflictingApps") /// Bundle identifiers of menu bar managers that conflict with holzBar. - /// - /// holzIce is the app holzBar used to be: the two register the same hotkeys - /// and manage the same items. private static let bundleIdentifiers: Set = [ - "com.holzcloud.holzIce", "com.jordanbaird.Ice", "com.surteesstudios.Bartender", "com.dwarvesv.minimalbar", ] /// Names of conflicting menu bar managers whose bundle identifier is not known. - private static let names: Set = ["holzIce", "Ice", "Thaw", "Bartender", "Hidden Bar"] + private static let names: Set = ["Ice", "Thaw", "Bartender", "Hidden Bar"] /// The running menu bar managers other than holzBar. static var running: [NSRunningApplication] { diff --git a/holzBar/Main/URLCommands.swift b/holzBar/Main/URLCommands.swift index 67c0632d..dc198df9 100644 --- a/holzBar/Main/URLCommands.swift +++ b/holzBar/Main/URLCommands.swift @@ -18,16 +18,15 @@ import OSLog /// - `holzbar://application-menus/toggle` /// - `holzbar://profile/` – apply a saved layout profile /// -/// `holzice://` URLs and the `ice-bar` command still work. +/// `ice-bar` still works as another name for `shelf`. @MainActor enum URLCommands { private static let logger = Logger(category: "URLCommands") - /// The URL schemes holzBar accepts: `holzbar://` is holzBar's own, and - /// `holzice://` keeps scripts written for holzIce working. - private static let schemes: Set = ["holzbar", "holzice"] + /// The URL scheme holzBar accepts. + private static let scheme = "holzbar" - /// Starts receiving `holzbar://` and `holzice://` URLs. + /// Starts receiving `holzbar://` URLs. static func register(appState: AppState) { Handler.shared.appState = appState NSAppleEventManager.shared().setEventHandler( @@ -40,7 +39,7 @@ enum URLCommands { /// Performs the command in the given URL. static func perform(_ url: URL, appState: AppState) { - guard let scheme = url.scheme?.lowercased(), schemes.contains(scheme), let host = url.host()?.lowercased() else { + guard let scheme = url.scheme?.lowercased(), scheme == Self.scheme, let host = url.host()?.lowercased() else { logger.warning("Ignoring URL \(url.absoluteString, privacy: .public)") return } diff --git a/holzBar/MenuBar/ControlItem/ControlItemImageSet.swift b/holzBar/MenuBar/ControlItem/ControlItemImageSet.swift index 4ae4f20e..fbe4c41a 100644 --- a/holzBar/MenuBar/ControlItem/ControlItemImageSet.swift +++ b/holzBar/MenuBar/ControlItem/ControlItemImageSet.swift @@ -17,25 +17,12 @@ struct ControlItemImageSet: Codable, Hashable, Identifiable { case iceCube = "Ice Cube" case sunglasses = "Sunglasses" case custom = "Custom" + } - /// The name the logo image set was stored under before the app - /// was renamed holzBar, decoded as ``logo``. - private static let previousLogoName = "holzIce" - - init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let rawValue = try container.decode(String.self) - if rawValue == Self.previousLogoName { - self = .logo - } else if let name = Name(rawValue: rawValue) { - self = name - } else { - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Unknown image set name \(rawValue)" - ) - } - } + private enum CodingKeys: String, CodingKey { + case name + case hidden + case visible } let name: Name @@ -50,6 +37,23 @@ struct ControlItemImageSet: Codable, Hashable, Identifiable { self.visible = visible } + /// Decodes a stored image set. A name this version does not know, such as + /// one stored by an older version, decodes as the default image set, so + /// old settings never keep the rest of the settings from loading. + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawName = try container.decode(String.self, forKey: .name) + guard let name = Name(rawValue: rawName) else { + self = .defaultHolzBarIcon + return + } + self.init( + name: name, + hidden: try container.decode(ControlItemImage.self, forKey: .hidden), + visible: try container.decode(ControlItemImage.self, forKey: .visible) + ) + } + init(name: Name, image: ControlItemImage) { self.init(name: name, hidden: image, visible: image) } diff --git a/holzBar/Resources/Info.plist b/holzBar/Resources/Info.plist index 59d84507..2c841293 100644 --- a/holzBar/Resources/Info.plist +++ b/holzBar/Resources/Info.plist @@ -10,7 +10,6 @@ CFBundleURLSchemes holzbar - holzice diff --git a/holzBar/Utilities/Migration.swift b/holzBar/Utilities/Migration.swift index d055f216..405cf9e8 100644 --- a/holzBar/Utilities/Migration.swift +++ b/holzBar/Utilities/Migration.swift @@ -55,37 +55,19 @@ extension MigrationManager { } } -// MARK: - Import Previous Settings +// MARK: - Import Ice Settings extension MigrationManager { - /// An app whose settings holzBar takes over. - private struct PreviousApp { - /// The app's name, for the log. - let name: String - /// The app's bundle identifier, which names its defaults domain. - let bundleIdentifier: String - /// The app's folder in Application Support and in iCloud Drive, if its - /// files are copied as well. - let folderName: String? - } - - /// The apps holzBar replaces, in order of precedence: holzIce, the app - /// holzBar used to be, then the original Ice, whose import stays - /// settings-only. - private static let previousApps = [ - PreviousApp(name: "holzIce", bundleIdentifier: "com.holzcloud.holzIce", folderName: "holzIce"), - PreviousApp(name: "Ice", bundleIdentifier: "com.jordanbaird.Ice", folderName: nil), - ] + /// The bundle identifier of the original Ice, which names its defaults domain. + private static let iceBundleIdentifier = "com.jordanbaird.Ice" - /// Takes over the settings of the app holzBar replaces, once. + /// Takes over the settings of the original Ice, once. /// /// holzBar has a bundle identifier of its own, so it starts out with empty - /// defaults, an empty Application Support folder and a sync file of its - /// own. Without this, a user switching from holzIce or Ice would lose their - /// layout, hotkeys and appearance. holzIce's settings take precedence; Ice's - /// are imported only when holzIce has none. It must run before anything - /// reads the defaults, so the app delegate calls it before it creates the - /// app state. + /// defaults. Without this, a user switching from Ice would lose their + /// layout, hotkeys and appearance. Only the settings are imported, no + /// files. It must run before anything reads the defaults, so the app + /// delegate calls it before it creates the app state. static func importPreviousSettingsIfNeeded() { let defaults = UserDefaults.standard let flag = Defaults.Key.hasImportedPreviousSettings.rawValue @@ -94,76 +76,26 @@ extension MigrationManager { } let ownSettings = Bundle.main.bundleIdentifier.flatMap(defaults.persistentDomain(forName:)) ?? [:] defaults.set(true, forKey: flag) - guard ownSettings.isEmpty else { - return - } - for app in previousApps { - guard - let settings = defaults.persistentDomain(forName: app.bundleIdentifier), - !settings.isEmpty - else { - continue - } - // Window frames and status item positions belong to the previous - // app's own windows and items, not to holzBar's. The first launch - // after importing them locked up a Mac on macOS 27 until it was - // restarted; the next launch was fine. - let imported = settings.filter { key, _ in - !SettingsBackup.excludedKeyPrefixes.contains { key.hasPrefix($0) } - } - for (key, value) in imported { - defaults.set(value, forKey: key) - } - Logger(category: "Migration").notice( - "Imported \(imported.count, privacy: .public) of \(settings.count, privacy: .public) settings from \(app.name, privacy: .public)" - ) - if let folderName = app.folderName { - copyFiles(fromFolderNamed: folderName) - } - return - } - } - - /// Copies a previous app's Application Support folder and iCloud Drive - /// sync file to holzBar's. - /// - /// The previous app may still be installed, and the user may go back to - /// it, so its files are copied, never moved or deleted, and nothing holzBar - /// already has is overwritten. A failed copy is logged and does not stop - /// the launch. - private static func copyFiles(fromFolderNamed folderName: String) { - if let applicationSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - copyItem( - at: applicationSupport.appending(path: folderName, directoryHint: .isDirectory), - to: applicationSupport.appending(path: "holzBar", directoryHint: .isDirectory) - ) - } - if let iCloudDrive = SettingsSync.iCloudDriveURL, let fileURL = SettingsSync.fileURL { - copyItem(at: iCloudDrive.appending(path: "\(folderName)/Settings.plist"), to: fileURL) - } - } - - /// Copies an item when it exists and its destination does not, creating - /// the destination's parent folder. - private static func copyItem(at source: URL, to destination: URL) { - let fileManager = FileManager.default - let logger = Logger(category: "Migration") guard - fileManager.fileExists(atPath: source.path), - !fileManager.fileExists(atPath: destination.path) + ownSettings.isEmpty, + let settings = defaults.persistentDomain(forName: iceBundleIdentifier), + !settings.isEmpty else { return } - do { - try fileManager.createDirectory( - at: destination.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try fileManager.copyItem(at: source, to: destination) - logger.notice("Copied \(source.path, privacy: .public) to \(destination.path, privacy: .public)") - } catch { - logger.error("Could not copy \(source.path, privacy: .public): \(error, privacy: .public)") + // Window frames and status item positions belong to Ice's own windows + // and items, not to holzBar's. The first launch after importing them + // locked up a Mac on macOS 27 until it was restarted; the next launch + // was fine. + let imported = settings.filter { key, _ in + !SettingsBackup.excludedKeyPrefixes.contains { key.hasPrefix($0) } + } + for (key, value) in imported { + defaults.set(value, forKey: key) } + Logger(category: "Migration").notice( + "Imported \(imported.count, privacy: .public) of \(settings.count, privacy: .public) settings from Ice" + ) } } From 8c342bc7aa20118d2cb57f5dbfa646d851ea7cde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:37:32 +0000 Subject: [PATCH 14/24] docs: holzBar is the only name - README drops the section for users of the former name and the gallery caption - NOTICE, CLAUDE.md and the Raycast README name only holzBar and Ice - Past release notes use the holzBar tap, cask, app and URLs - CLAUDE.md forbids the former name; a former-name job in build.yml enforces it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .github/workflows/build.yml | 32 ++++++++++++++++++++++++++++++++ CLAUDE.md | 17 +++++++++-------- Integrations/Raycast/README.md | 2 +- NOTICE | 5 ++--- README.md | 29 ----------------------------- docs/release-notes/v0.0.1.md | 20 ++++++++++---------- docs/release-notes/v0.0.2.md | 28 ++++++++++++++-------------- docs/release-notes/v0.0.3.md | 20 ++++++++++---------- docs/release-notes/v0.0.4.md | 18 +++++++++--------- docs/release-notes/v0.0.5.md | 22 +++++++++++----------- 10 files changed, 98 insertions(+), 95 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b5cc63c5..1c439622 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,6 +63,38 @@ jobs: exit 1 fi + # The app's former name must not appear anywhere in the repository (see CLAUDE.md). + # .planning/ and .claude/ are GSD's working state; .github/cms-version.py is a copy + # of a tool maintained in holzcloud-design. The pattern is written so that it does + # not match itself. + former-name: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Check for the former name + run: | + PATTERN='holz[ -]?[i]ce' + EXCLUDE=(':(exclude).planning' ':(exclude).claude' ':(exclude).github/cms-version.py') + set +e + git grep -n -i -E "$PATTERN" -- . "${EXCLUDE[@]}" + STATUS=$? + set -e + if [ "$STATUS" -eq 0 ]; then + echo "::error::The former name appears in the lines above." + exit 1 + elif [ "$STATUS" -ne 1 ]; then + echo "::error::git grep failed with status $STATUS." + exit "$STATUS" + fi + NAMES=$(git ls-files -- . "${EXCLUDE[@]}" | grep -i -E "$PATTERN" || true) + if [ -n "$NAMES" ]; then + echo "$NAMES" + echo "::error::The former name appears in the file names above." + exit 1 + fi + echo "==> The former name appears nowhere." + test: runs-on: macos-26 steps: diff --git a/CLAUDE.md b/CLAUDE.md index 1c2c1091..fda3a7a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # holzBar -holzBar (formerly holzIce) is a fork of [Ice](https://github.com/jordanbaird/Ice) by Jordan Baird, a menu bar manager for macOS, with macOS 27 support and Homebrew distribution. +holzBar is a fork of [Ice](https://github.com/jordanbaird/Ice) by Jordan Baird, a menu bar manager for macOS, with macOS 27 support and Homebrew distribution. ## Rules @@ -28,11 +28,12 @@ These hold for every change, always, without taking a feature away: ## Naming -- The app is called **holzBar** (exactly so) everywhere a user can see it (UI strings, menus, README). The bundle identifier is `com.holzcloud.holzBar`, the product is `holzBar.app`, the XPC service is `com.holzcloud.holzBar.MenuBarItemService`, and the URL scheme is `holzbar://` with `holzice://` as an alias. Ice's bar is the **holzBar Shelf**. +- The app is called **holzBar** (exactly so) everywhere a user can see it (UI strings, menus, README). The bundle identifier is `com.holzcloud.holzBar`, the product is `holzBar.app`, the XPC service is `com.holzcloud.holzBar.MenuBarItemService`, and the URL scheme is `holzbar://`. Ice's bar is the **holzBar Shelf**. - Internal names say holzBar too: the Xcode project `holzBar.xcodeproj`, target, scheme and Swift module `holzBar`, the source folder `holzBar/`, the test package `HolzBarMacOS27Core`. Types that carry the app's name are `HolzBar` (`HolzBarSection`, `HolzBarForm`, …), the Shelf's are `HolzBarShelf` with members `shelf`, and the holzBar icon's are `holzBarIcon`. -- Persisted strings keep their old names: `Defaults.Key` and `HotkeyAction` raw values (such as `HasImportedIceSettings`) and the hotkey signature `OSType(1231250720)`. Renaming them would lose every user's settings and hotkeys, and the imports of holzIce and Ice settings rely on them. +- Persisted strings keep their old names: `Defaults.Key` and `HotkeyAction` raw values (such as `HasImportedIceSettings`) and the hotkey signature `OSType(1231250720)`. Renaming them would lose every user's settings and hotkeys, and the import of Ice settings relies on them. - Links in the app and the repository lead to holzBar (`Constants.repositoryURL`, `Constants.issuesURL`, `Constants.websiteURL`). The only exception is the credit to the original: "Based on Ice by Jordan Baird" in the About pane (`Constants.originalIceURL`), the README and NOTICE. The website is https://holzcloud.ch/holzbar (`Constants.websiteURL`, README, cask `homepage`). -- Keep `com.jordanbaird.Ice` and `com.holzcloud.holzIce` (and the names Ice and holzIce) only where they refer to those apps: importing their settings, the conflicting-app check, the `holzice://` alias, the cask rename and the `jordanbaird-ice` cask conflict, and the README's "Coming from holzIce" section. +- Keep `com.jordanbaird.Ice` (and the name Ice) only where it refers to the original app: importing its settings, the conflicting-app check and the `jordanbaird-ice` cask conflict. +- The app's former name (`holz` followed by `Ice`, in any capitalization or spelling) must not appear anywhere in the repository. The `former-name` job in `.github/workflows/build.yml` fails when it does; only `.planning/`, `.claude/` and `.github/cms-version.py` are exempt. ## Releases @@ -48,12 +49,12 @@ These hold for every change, always, without taking a feature away: ## Layout - `holzBar/` – the app. `holzBar/MenuBar/MacOS27/` is the macOS 27 backend (its core is the Swift package `HolzBarMacOS27Core`, tested by `swift test`). -- `Casks/holzbar.rb` – the Homebrew cask; this repository is also the tap. `cask_renames.json` moves installs of the old `holzice` cask to `holzbar`. -- `.github/workflows/build.yml` – builds every pull request on a macOS runner; the only way to compile without a Mac. +- `Casks/holzbar.rb` – the Homebrew cask; this repository is also the tap. +- `.github/workflows/build.yml` – builds every pull request on a macOS runner (the only way to compile without a Mac), runs `swift test` and checks that the former name appears nowhere. - `.github/workflows/release.yml` – a `v*` tag builds the app on a macOS runner, publishes the release and updates the cask on `main`; a second job writes the version into the app's pages on holzcloud.ch (`.github/cms-version.py`, skipped for betas and without the `CMS_TOKEN` secret). -- `.github/workflows/cask.yml` – for every pull request that touches the cask: `brew style`, `brew audit` and the move of an installed holzice to holzbar on a macOS runner. +- `.github/workflows/cask.yml` – for every pull request that touches the cask: `brew style` and `brew audit` of the cask from this repository as the tap, on a macOS runner. - `Resources/Logo/` – logo and README banner sources (SVG). -- `Resources/Screenshots/` – real screenshots of the app, used by the README gallery (and on https://holzcloud.ch/holzbar). Most were taken when it was called holzIce; replace those, and the one of the original Ice, with screenshots of holzBar when they exist. +- `Resources/Screenshots/` – real screenshots of the app, used by the README gallery (and on https://holzcloud.ch/holzbar). Replace any that do not show holzBar as it is now, and the one of the original Ice, with new screenshots of holzBar when they exist. - `docs/upstream-bugs.md` – the open bug reports of the original Ice, grouped, and which of them holzBar has fixed. Update it when fixing one. ## Building diff --git a/Integrations/Raycast/README.md b/Integrations/Raycast/README.md index fd96112a..8eeb7356 100644 --- a/Integrations/Raycast/README.md +++ b/Integrations/Raycast/README.md @@ -18,4 +18,4 @@ The scripts open `holzbar://` URLs, so the same commands work from Alfred, Short | `holzbar://application-menus/toggle` | Hide or show the application menus | | `holzbar://profile/` | Apply a saved layout profile | -The `holzice://` URLs from holzIce, including `holzice://ice-bar/toggle`, still work. +`holzbar://ice-bar/toggle`, the command's name in Ice, still works. diff --git a/NOTICE b/NOTICE index ad7b8526..d9a8486e 100644 --- a/NOTICE +++ b/NOTICE @@ -20,9 +20,8 @@ Changes made in holzBar since forking on 2026-10-01 include: - New features: layout profiles, groups, spacers, placement of new items, per-display holzBar Shelf, notch overflow, black menu bar, rounded screen corners, reveal rules, URL commands, settings export, import and sync. - - A new name (holzBar, bundle identifier com.holzcloud.holzBar; first - released as holzIce, com.holzcloud.holzIce), logo, app icon and menu bar - icon; settings of holzIce and of Ice are imported on first launch. + - A new name (holzBar, bundle identifier com.holzcloud.holzBar), logo, app + icon and menu bar icon; settings of Ice are imported on first launch. The full history of changes is available in this repository's Git history. diff --git a/README.md b/README.md index d361f129..1a79d923 100644 --- a/README.md +++ b/README.md @@ -71,33 +71,6 @@ Update with: brew update && brew upgrade --cask holzbar ``` -### Coming from holzIce - -holzIce is now holzBar. If you installed holzIce with Homebrew, trust the new cask name once, then update as usual: - -```sh -brew trust --cask holzcloud/holzice/holzbar -brew update && brew upgrade --cask holzbar -``` - -Your tap `holzcloud/holzice` keeps working: Homebrew knows the cask was renamed and moves your install to `holzbar` during `brew update`. The new trust is needed because Homebrew trusts casks by name and only loads a renamed cask you have trusted; trusting `holzcloud/holzice/holzice` does not help, and `brew install --cask holzice` asks you to trust `holzbar`. If you ran `brew update` before trusting it, run `brew migrate --cask holzice` once after `brew trust`. Until the first holzBar release there is nothing new to download; you keep holzIce 0.0.5 under the new name. - -To switch to the new tap name instead (recommended for a fresh install): - -```sh -brew untap --force holzcloud/holzice -brew tap holzcloud/holzbar https://github.com/holzcloud/holzBar -brew trust --cask holzcloud/holzbar/holzbar -brew install --cask holzbar -``` - -On its first launch, holzBar takes over holzIce's settings, layout profiles, item images and iCloud sync file, and offers to quit holzIce. Then: - -- macOS asks for Accessibility and Screen Recording again, because holzBar is a new app to it. -- Turn on **Launch at login** again. -- `holzice://` URLs and the old Raycast scripts keep working; the new scripts use `holzbar://`. -- Sync between Macs continues once every Mac runs holzBar. - ### If macOS says holzBar "can't be opened" holzBar is signed ad hoc, without an Apple Developer ID. The Homebrew cask removes the quarantine flag for you, but if you downloaded the zip yourself — or macOS still blocks the app — take it out of quarantine: @@ -217,8 +190,6 @@ macOS 27 no longer draws menu bar items as separate windows — `MenuBarAgent` d ## 🖼 Gallery -These screenshots were taken when holzBar was called holzIce and show that name until they are retaken. -

holzBar settings, General pane: Launch at login, the holzBar icon, the holzBar Shelf and showing hidden items on click, hover or scroll

diff --git a/docs/release-notes/v0.0.1.md b/docs/release-notes/v0.0.1.md index 5885c315..f7a79323 100644 --- a/docs/release-notes/v0.0.1.md +++ b/docs/release-notes/v0.0.1.md @@ -1,10 +1,10 @@ -## holzIce 0.0.1 — first beta +## holzBar 0.0.1 — first beta -The first release of holzIce, a fork of [Ice](https://github.com/jordanbaird/Ice) by Jordan Baird that keeps it working on macOS 27. +The first release of holzBar, a fork of [Ice](https://github.com/jordanbaird/Ice) by Jordan Baird that keeps it working on macOS 27. ### Highlights -- **macOS 27 support** — a new backend for the redesigned menu bar ([jordanbaird/Ice#995](https://github.com/jordanbaird/Ice/pull/995)): hiding, the holzIce Bar and the layout editor work again. Your old layout is carried over on first launch. -- **Homebrew** — install and update with `brew install --cask holzice`. +- **macOS 27 support** — a new backend for the redesigned menu bar ([jordanbaird/Ice#995](https://github.com/jordanbaird/Ice/pull/995)): hiding, the holzBar Shelf and the layout editor work again. Your old layout is carried over on first launch. +- **Homebrew** — install and update with `brew install --cask holzbar`. - **Your Ice settings come along** — layout, hotkeys and appearance are imported from Ice on first launch. ### Fixed @@ -16,7 +16,7 @@ The first release of holzIce, a fork of [Ice](https://github.com/jordanbaird/Ice ### Changed - New name, logo, app icon and menu bar icon. -- Built-in updates (Sparkle) are turned off; holzIce updates through Homebrew. +- Built-in updates (Sparkle) are turned off; holzBar updates through Homebrew. ### Known issues - On macOS 27, items can't be reordered on the bar itself, only assigned to sections. @@ -24,12 +24,12 @@ The first release of holzIce, a fork of [Ice](https://github.com/jordanbaird/Ice ### Install ```sh -brew tap holzcloud/holzice https://github.com/holzcloud/holzIce -brew trust --cask holzcloud/holzice/holzice -brew install --cask holzice +brew tap holzcloud/holzbar https://github.com/holzcloud/holzBar +brew trust --cask holzcloud/holzbar/holzbar +brew install --cask holzbar ``` -If macOS says holzIce can't be opened, take it out of quarantine and open it again: +If macOS says holzBar can't be opened, take it out of quarantine and open it again: ```sh -xattr -dr com.apple.quarantine /Applications/holzIce.app +xattr -dr com.apple.quarantine /Applications/holzBar.app ``` diff --git a/docs/release-notes/v0.0.2.md b/docs/release-notes/v0.0.2.md index c26a3993..aa80974d 100644 --- a/docs/release-notes/v0.0.2.md +++ b/docs/release-notes/v0.0.2.md @@ -1,19 +1,19 @@ -## holzIce 0.0.2 — beta +## holzBar 0.0.2 — beta A big feature release: 14 new features, most of them long-standing requests from the original Ice. > [!NOTE] -> This is a beta and has not been tested on a Mac yet. Please report anything that doesn't work on the [issue tracker](https://github.com/holzcloud/holzIce/issues). +> This is a beta and has not been tested on a Mac yet. Please report anything that doesn't work on the [issue tracker](https://github.com/holzcloud/holzBar/issues). ### ✨ New **Menu bar items** -- **Layout profiles** — save your layout as "Work", "Home", … and switch with one click (Settings → Menu Bar Layout) or `holzice://profile/`. +- **Layout profiles** — save your layout as "Work", "Home", … and switch with one click (Settings → Menu Bar Layout) or `holzbar://profile/`. - **Groups** — put several items behind an icon of their own; click it to see and use them. - **Spacers** — up to ten empty items of adjustable width. - **Choose where new items appear** — visible, hidden or always-hidden (Settings → Advanced). -**holzIce Bar** +**holzBar Shelf** - **Use it only on the built-in display or on displays with a notch** (Settings → General). - **Shows items the notch covers**, for example behind a long app menu. @@ -23,7 +23,7 @@ A big feature release: 14 new features, most of them long-standing requests from **Automation** - **Show hidden items automatically** when the battery runs low or the network drops (Settings → Advanced). -- **`holzice://` URL commands** and ready-made **Raycast script commands** ([Integrations/Raycast](https://github.com/holzcloud/holzIce/tree/main/Integrations/Raycast)). +- **`holzbar://` URL commands** and ready-made **Raycast script commands** ([Integrations/Raycast](https://github.com/holzcloud/holzBar/tree/main/Integrations/Raycast)). - **New hotkeys:** show the hidden section for a moment; turn auto-rehide on or off. **Settings** @@ -31,14 +31,14 @@ A big feature release: 14 new features, most of them long-standing requests from - **Sync between Macs** through iCloud Drive. **Experimental** -- **Keep Live Activities visible** — the process that draws them isn't known yet; if they still get hidden, please attach the output of `log show --last 10m --predicate 'subsystem == "com.holzcloud.holzIce"' | grep "Hidden system item"` to an issue. +- **Keep Live Activities visible** — the process that draws them isn't known yet; if they still get hidden, please attach the output of `log show --last 10m --predicate 'subsystem == "com.holzcloud.holzBar"' | grep "Hidden system item"` to an issue. ### 🔧 Changed -- Sparkle is removed completely; holzIce updates through Homebrew. "Check for Updates…" opens the releases page. +- Sparkle is removed completely; holzBar updates through Homebrew. "Check for Updates…" opens the releases page. - "Hide application menus" is turned off on macOS 27, where macOS folds overflowing items itself. ### 🧪 For testers -- macOS 27: run `Scripts/macos27/reorder-probe.swift` (see [docs/macos27.md](https://github.com/holzcloud/holzIce/blob/main/docs/macos27.md)) to find out whether items can be reordered on the bar. +- macOS 27: run `Scripts/macos27/reorder-probe.swift` (see [docs/macos27.md](https://github.com/holzcloud/holzBar/blob/main/docs/macos27.md)) to find out whether items can be reordered on the bar. ### ⚠️ Known issues - On macOS 27, items can't be reordered on the bar itself, only assigned to sections. @@ -46,13 +46,13 @@ A big feature release: 14 new features, most of them long-standing requests from ### 📦 Install or update ```sh -brew tap holzcloud/holzice https://github.com/holzcloud/holzIce -brew trust --cask holzcloud/holzice/holzice -brew install --cask holzice # first install -brew update && brew upgrade --cask holzice # update +brew tap holzcloud/holzbar https://github.com/holzcloud/holzBar +brew trust --cask holzcloud/holzbar/holzbar +brew install --cask holzbar # first install +brew update && brew upgrade --cask holzbar # update ``` -If macOS says holzIce can't be opened, take it out of quarantine and open it again: +If macOS says holzBar can't be opened, take it out of quarantine and open it again: ```sh -xattr -dr com.apple.quarantine /Applications/holzIce.app +xattr -dr com.apple.quarantine /Applications/holzBar.app ``` diff --git a/docs/release-notes/v0.0.3.md b/docs/release-notes/v0.0.3.md index ad731376..5de22f64 100644 --- a/docs/release-notes/v0.0.3.md +++ b/docs/release-notes/v0.0.3.md @@ -1,25 +1,25 @@ -## holzIce 0.0.3 — beta +## holzBar 0.0.3 — beta ### ✨ New look - **New logo and app icon**: a wooden menu bar with an ice-cube chevron — items slide into the ice to hide. -- **New menu bar icon** to match, now the default ("holzIce" in Settings → General → holzIce icon). It is also used in the settings sidebar and the search panel. +- **New menu bar icon** to match, now the default ("holzBar" in Settings → General → holzBar icon). It is also used in the settings sidebar and the search panel. -Everything else is the same as in [0.0.2](https://github.com/holzcloud/holzIce/releases/tag/v0.0.2). +Everything else is the same as in [0.0.2](https://github.com/holzcloud/holzBar/releases/tag/v0.0.2). ### ⚠️ Known issues -- If you picked the "Crystal" menu bar icon in 0.0.2, holzIce falls back to the new default icon. +- If you picked the "Crystal" menu bar icon in 0.0.2, holzBar falls back to the new default icon. - On macOS 27, items can't be reordered on the bar itself, only assigned to sections. - The app is signed ad hoc; after an update macOS may ask for Accessibility again — use **Reset and Grant Again**. ### 📦 Install or update ```sh -brew tap holzcloud/holzice https://github.com/holzcloud/holzIce -brew trust --cask holzcloud/holzice/holzice -brew install --cask holzice # first install -brew update && brew upgrade --cask holzice # update +brew tap holzcloud/holzbar https://github.com/holzcloud/holzBar +brew trust --cask holzcloud/holzbar/holzbar +brew install --cask holzbar # first install +brew update && brew upgrade --cask holzbar # update ``` -If macOS says holzIce can't be opened, take it out of quarantine and open it again: +If macOS says holzBar can't be opened, take it out of quarantine and open it again: ```sh -xattr -dr com.apple.quarantine /Applications/holzIce.app +xattr -dr com.apple.quarantine /Applications/holzBar.app ``` diff --git a/docs/release-notes/v0.0.4.md b/docs/release-notes/v0.0.4.md index c1481ad6..3cdba716 100644 --- a/docs/release-notes/v0.0.4.md +++ b/docs/release-notes/v0.0.4.md @@ -1,10 +1,10 @@ -## holzIce 0.0.4 — beta +## holzBar 0.0.4 — beta ### ✨ New look - **New logo and app icon** from the holzcloud family: an ice cube carrying the chevron, standing on the wooden plank. -- **New menu bar icon** to match: the ice cube with its chevron on a short plank. It stays the default ("holzIce" in Settings → General → holzIce icon), and your choice of icon is kept. +- **New menu bar icon** to match: the ice cube with its chevron on a short plank. It stays the default ("holzBar" in Settings → General → holzBar icon), and your choice of icon is kept. -Everything else is the same as in [0.0.3](https://github.com/holzcloud/holzIce/releases/tag/v0.0.3). +Everything else is the same as in [0.0.3](https://github.com/holzcloud/holzBar/releases/tag/v0.0.3). ### ⚠️ Known issues - On macOS 27, items can't be reordered on the bar itself, only assigned to sections. @@ -12,13 +12,13 @@ Everything else is the same as in [0.0.3](https://github.com/holzcloud/holzIce/r ### 📦 Install or update ```sh -brew tap holzcloud/holzice https://github.com/holzcloud/holzIce -brew trust --cask holzcloud/holzice/holzice -brew install --cask holzice # first install -brew update && brew upgrade --cask holzice # update +brew tap holzcloud/holzbar https://github.com/holzcloud/holzBar +brew trust --cask holzcloud/holzbar/holzbar +brew install --cask holzbar # first install +brew update && brew upgrade --cask holzbar # update ``` -If macOS says holzIce can't be opened, take it out of quarantine and open it again: +If macOS says holzBar can't be opened, take it out of quarantine and open it again: ```sh -xattr -dr com.apple.quarantine /Applications/holzIce.app +xattr -dr com.apple.quarantine /Applications/holzBar.app ``` diff --git a/docs/release-notes/v0.0.5.md b/docs/release-notes/v0.0.5.md index 291452eb..94e1ccf9 100644 --- a/docs/release-notes/v0.0.5.md +++ b/docs/release-notes/v0.0.5.md @@ -1,9 +1,9 @@ -## holzIce 0.0.5 — beta +## holzBar 0.0.5 — beta ### 🛠 Fixed -- **"Loading menu bar items…" forever on macOS 26** in the layout settings and the holzIce Bar. On macOS 26 every menu bar item belongs to Control Center, and holzIce asks a helper service which app is behind each one. On macOS 26.7.1 the helper could not be started at all, so holzIce did not even recognise its own section dividers and showed nothing. holzIce now recognises its own items directly, and when the helper fails it does the same lookup itself. -- **The first launch could lock up the Mac** on macOS 27 — the pointer still moved, but no click or key got through until a restart. It happened only once, on the launch that imported the settings of the original Ice. holzIce no longer imports Ice's window frames and menu bar item positions, which belong to Ice's own windows and items. -- **holzIce no longer runs alongside another menu bar manager.** When it finds Ice, Bartender, Hidden Bar or Thaw running, it offers to quit them before it continues — two managers fight over the same items. +- **"Loading menu bar items…" forever on macOS 26** in the layout settings and the holzBar Shelf. On macOS 26 every menu bar item belongs to Control Center, and holzBar asks a helper service which app is behind each one. On macOS 26.7.1 the helper could not be started at all, so holzBar did not even recognise its own section dividers and showed nothing. holzBar now recognises its own items directly, and when the helper fails it does the same lookup itself. +- **The first launch could lock up the Mac** on macOS 27 — the pointer still moved, but no click or key got through until a restart. It happened only once, on the launch that imported the settings of the original Ice. holzBar no longer imports Ice's window frames and menu bar item positions, which belong to Ice's own windows and items. +- **holzBar no longer runs alongside another menu bar manager.** When it finds Ice, Bartender, Hidden Bar or Thaw running, it offers to quit them before it continues — two managers fight over the same items. ### ⚠️ Known issues - On macOS 27, items can't be reordered on the bar itself, only assigned to sections. @@ -12,15 +12,15 @@ ### 📦 Install or update Quit the original Ice first if it is running. ```sh -brew tap holzcloud/holzice https://github.com/holzcloud/holzIce -brew trust --cask holzcloud/holzice/holzice -brew install --cask holzice # first install -brew update && brew upgrade --cask holzice # update +brew tap holzcloud/holzbar https://github.com/holzcloud/holzBar +brew trust --cask holzcloud/holzbar/holzbar +brew install --cask holzbar # first install +brew update && brew upgrade --cask holzbar # update ``` -If macOS says holzIce can't be opened, take it out of quarantine and open it again: +If macOS says holzBar can't be opened, take it out of quarantine and open it again: ```sh -xattr -dr com.apple.quarantine /Applications/holzIce.app +xattr -dr com.apple.quarantine /Applications/holzBar.app ``` -If the Mac ever stops responding while holzIce runs, wait about ten seconds; if the keyboard still works, press **⌘ Option Esc**, select holzIce and click **Force Quit**. Please report it on the [issue tracker](https://github.com/holzcloud/holzIce/issues). +If the Mac ever stops responding while holzBar runs, wait about ten seconds; if the keyboard still works, press **⌘ Option Esc**, select holzBar and click **Force Quit**. Please report it on the [issue tracker](https://github.com/holzcloud/holzBar/issues). From 25e5a8eace0a47b35d5e49ac78f2502b179bcd3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:41:58 +0000 Subject: [PATCH 15/24] docs: quick task summary (former name removed) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .planning/quick/remove-former-name/SUMMARY.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .planning/quick/remove-former-name/SUMMARY.md diff --git a/.planning/quick/remove-former-name/SUMMARY.md b/.planning/quick/remove-former-name/SUMMARY.md new file mode 100644 index 00000000..e6d2b3db --- /dev/null +++ b/.planning/quick/remove-former-name/SUMMARY.md @@ -0,0 +1,26 @@ +--- +status: complete +completed: 2026-10-02 +--- +# Quick task: remove the former app name + +Only holzBar remains. Everything about the original Ice by Jordan Baird stays. + +- Homebrew: + - `cask_renames.json` is deleted. + - `Casks/holzbar.rb` points only at holzBar; version and sha256 are unchanged. + - `release.yml` only updates version and sha256. + - `cask.yml` runs `brew style` and `brew audit --cask --strict` from the local tap. +- App: + - The settings import only reads Ice's settings; its flag key is kept and the file copies are removed. + - The former app is no longer in `ConflictingApps`. + - Its URL scheme alias is removed; the `ice-bar` alias stays. + - An unknown stored image set name now decodes as the default image set. +- Docs: + - The migration section and the old gallery caption are gone from the README. + - NOTICE, CLAUDE.md and the Raycast README are updated. + - Release notes v0.0.1 to v0.0.5 now say holzBar. +- CLAUDE.md forbids the former name. The `former-name` job in `build.yml` enforces this everywhere except `.planning/`, `.claude/` and `.github/cms-version.py`. +- `.github/cms-version.py` is not touched. It is website tooling that still uses the old page slug. + +Commits: 397e7e7, 7f00592, 8c342bc. CI is green on 8c342bc (build, test, swiftlint, cask, former-name). From 6fb70fd065d5c40c6f44be5b918fc12519cf6194 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:46:04 +0000 Subject: [PATCH 16/24] test(02-03): prove the code directory hashes that pin a process - Shared/CodeSigning/CodeSignature.swift reads the team of this process and the signing identifier and per-slice code directory hashes of code on disk (Security only, so the app keeps launching on macOS 14.0) - Package.swift compiles it as SharedCodeSigning with a test target - The CodeSignature suite checks the hashes against a running process with SecCodeCheckValidityWithProcessRequirement Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- Package.swift | 12 ++ Shared/CodeSigning/CodeSignature.swift | 172 ++++++++++++++++++ .../CodeSignatureTests.swift | 91 +++++++++ 3 files changed, 275 insertions(+) create mode 100644 Shared/CodeSigning/CodeSignature.swift create mode 100644 Tests/SharedCodeSigningTests/CodeSignatureTests.swift diff --git a/Package.swift b/Package.swift index 8ac30e6c..7c176bc2 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,7 @@ import PackageDescription // Test-only package. It compiles holzBar's pure logic, `holzBar/Core` (any macOS) and // `holzBar/MenuBar/MacOS27/Core` (macOS 27), so it can be unit tested with `swift test`. // The app compiles the same files through the synchronized `holzBar` folder group. +// It also compiles `Shared/CodeSigning`, the code signing helpers shared by the app and the XPC service. let package = Package( name: "HolzBarMacOS27Core", platforms: [.macOS(.v14)], @@ -30,5 +31,16 @@ let package = Package( path: "Tests/HolzBarCoreTests", swiftSettings: [.swiftLanguageMode(.v5)] ), + .target( + name: "SharedCodeSigning", + path: "Shared/CodeSigning", + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget( + name: "SharedCodeSigningTests", + dependencies: ["SharedCodeSigning"], + path: "Tests/SharedCodeSigningTests", + swiftSettings: [.swiftLanguageMode(.v5)] + ), ] ) diff --git a/Shared/CodeSigning/CodeSignature.swift b/Shared/CodeSigning/CodeSignature.swift new file mode 100644 index 00000000..bae07a4f --- /dev/null +++ b/Shared/CodeSigning/CodeSignature.swift @@ -0,0 +1,172 @@ +// +// CodeSignature.swift +// Shared +// + +import Foundation +import Security + +/// The code signing facts that the XPC peer check between holzBar and its +/// menu bar item service needs. +/// +/// This file uses only Security, so it compiles into the app (which still +/// launches on macOS 14.0) as well as into the service. +enum CodeSignature { + /// An error that says which code signing call failed, and how. + struct Failure: Error, CustomStringConvertible { + /// The code signing call that failed, or a short reason. + let operation: String + + /// The status the call returned, if it returned one. + let status: OSStatus? + + init(_ operation: String, status: OSStatus? = nil) { + self.operation = operation + self.status = status + } + + var description: String { + if let status { + return "\(operation) failed with status \(status)" + } + return operation + } + } + + /// The architecture slices whose code directory hashes are collected, + /// besides the default slice. + private static let architectures = ["arm64", "arm64e", "x86_64"] + + /// The team identifier of this process's signature. + /// + /// The value is `nil` when the signature has no team (ad hoc signing) or + /// cannot be read. It is read once, the first time it is used. + static let currentTeamIdentifier: String? = { + guard + let code = try? currentStaticCode(), + let information = try? signingInformation(of: code) + else { + return nil + } + return information[kSecCodeInfoTeamIdentifier as String] as? String + }() + + /// Returns the location of this process's code on disk. + static func currentCodeURL() throws -> URL { + let code = try currentStaticCode() + var url: CFURL? + try check(SecCodeCopyPath(code, [], &url), "SecCodeCopyPath") + guard let url else { + throw Failure("SecCodeCopyPath returned no path") + } + return url as URL + } + + /// Returns the signing identifier of the code at the given location. + static func signingIdentifier(ofCodeAt url: URL) throws -> String { + let code = try staticCode(at: url) + let information = try signingInformation(of: code) + guard + let identifier = information[kSecCodeInfoIdentifier as String] as? String, + !identifier.isEmpty + else { + throw Failure("The code has no signing identifier") + } + return identifier + } + + /// Returns the code directory hashes of the code at the given location. + /// + /// These are the hashes that `CodeDirectoryHash` compares for a running + /// process: one or more per architecture slice (one per digest algorithm), + /// so a universal app matches on every architecture it runs as. The + /// signature is checked first, on every slice; unsigned or broken code + /// has nothing worth pinning, so this throws for it. + static func codeDirectoryHashes(ofCodeAt url: URL) throws -> [Data] { + let code = try staticCode(at: url) + let validityFlags = SecCSFlags(rawValue: UInt32(kSecCSCheckAllArchitectures)) + try check(SecStaticCodeCheckValidity(code, validityFlags, nil), "SecStaticCodeCheckValidity") + + var hashes = [Data]() + + func collectHashes(of code: SecStaticCode) throws { + let information = try signingInformation(of: code) + var found = [Data]() + if let unique = information[kSecCodeInfoUnique as String] as? Data { + found.append(unique) + } + if let list = information[kSecCodeInfoCdHashes as String] as? [Data] { + found += list + } + for hash in found where !hash.isEmpty && !hashes.contains(hash) { + hashes.append(hash) + } + } + + try collectHashes(of: code) + + for architecture in architectures { + let attributes = [kSecCodeAttributeArchitecture as String: architecture] as CFDictionary + var slice: SecStaticCode? + let status = SecStaticCodeCreateWithPathAndAttributes(url as CFURL, [], attributes, &slice) + guard status == errSecSuccess, let slice else { + // The binary has no slice for this architecture. + continue + } + try? collectHashes(of: slice) + } + + guard !hashes.isEmpty else { + throw Failure("The code has no code directory hash") + } + return hashes + } + + // MARK: Helpers + + /// Throws a failure unless the given status is `errSecSuccess`. + private static func check(_ status: OSStatus, _ operation: String) throws { + guard status == errSecSuccess else { + throw Failure(operation, status: status) + } + } + + /// Returns the static code of this process. + private static func currentStaticCode() throws -> SecStaticCode { + var code: SecCode? + try check(SecCodeCopySelf([], &code), "SecCodeCopySelf") + guard let code else { + throw Failure("SecCodeCopySelf returned no code") + } + var staticCode: SecStaticCode? + try check(SecCodeCopyStaticCode(code, [], &staticCode), "SecCodeCopyStaticCode") + guard let staticCode else { + throw Failure("SecCodeCopyStaticCode returned no code") + } + return staticCode + } + + /// Returns the static code at the given location. + private static func staticCode(at url: URL) throws -> SecStaticCode { + var code: SecStaticCode? + try check(SecStaticCodeCreateWithPath(url as CFURL, [], &code), "SecStaticCodeCreateWithPath") + guard let code else { + throw Failure("SecStaticCodeCreateWithPath returned no code") + } + return code + } + + /// Returns the signing information of the given static code. + private static func signingInformation(of code: SecStaticCode) throws -> [String: Any] { + var information: CFDictionary? + let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation)) + try check(SecCodeCopySigningInformation(code, flags, &information), "SecCodeCopySigningInformation") + guard + let information, + let dictionary = (information as NSDictionary) as? [String: Any] + else { + throw Failure("SecCodeCopySigningInformation returned no information") + } + return dictionary + } +} diff --git a/Tests/SharedCodeSigningTests/CodeSignatureTests.swift b/Tests/SharedCodeSigningTests/CodeSignatureTests.swift new file mode 100644 index 00000000..5cfd64a0 --- /dev/null +++ b/Tests/SharedCodeSigningTests/CodeSignatureTests.swift @@ -0,0 +1,91 @@ +import Foundation +import LightweightCodeRequirements +import Security +import Testing +@testable import SharedCodeSigning + +/// Proves that the code directory hashes `CodeSignature` reads from disk are the +/// ones `CodeDirectoryHash` compares for a running process. The menu bar item +/// service pins holzBar's ad hoc build with exactly such a requirement. +@Suite("CodeSignature") +struct CodeSignatureTests { + @Test("The hashes of this process's code are distinct 20-byte hashes") + func hashesOfThisProcess() throws { + let hashes = try CodeSignature.codeDirectoryHashes(ofCodeAt: CodeSignature.currentCodeURL()) + #expect(!hashes.isEmpty) + #expect(hashes.allSatisfy { $0.count == 20 }, "hash sizes: \(hashes.map(\.count))") + #expect(Set(hashes).count == hashes.count, "\(hashes.count) hashes") + } + + @Test("This process's code has a signing identifier") + func signingIdentifierOfThisProcess() throws { + let identifier = try CodeSignature.signingIdentifier(ofCodeAt: CodeSignature.currentCodeURL()) + #expect(!identifier.isEmpty) + } + + @Test("A requirement on identifier and hashes matches this process") + @available(macOS 15.0, *) + func requirementMatchesThisProcess() throws { + let url = try CodeSignature.currentCodeURL() + let identifier = try CodeSignature.signingIdentifier(ofCodeAt: url) + let hashes = try CodeSignature.codeDirectoryHashes(ofCodeAt: url) + let result = try validateThisProcess(identifier: identifier, hashes: hashes) + #expect( + result.signatureIsValid, + "failureReason \(result.failureReason), \(hashes.count) hashes, identifier \(identifier)" + ) + #expect( + result.requirementMatched, + "failureReason \(result.failureReason), \(hashes.count) hashes, identifier \(identifier)" + ) + } + + @Test("Another signing identifier does not match") + @available(macOS 15.0, *) + func otherIdentifierDoesNotMatch() throws { + let hashes = try CodeSignature.codeDirectoryHashes(ofCodeAt: CodeSignature.currentCodeURL()) + let result = try validateThisProcess(identifier: "com.example.not-this-process", hashes: hashes) + #expect( + !result.requirementMatched, + "failureReason \(result.failureReason), \(hashes.count) hashes" + ) + } + + @Test("Another program's hashes do not match") + @available(macOS 15.0, *) + func otherProgramsHashesDoNotMatch() throws { + let url = try CodeSignature.currentCodeURL() + let identifier = try CodeSignature.signingIdentifier(ofCodeAt: url) + let ownHashes = try CodeSignature.codeDirectoryHashes(ofCodeAt: url) + let otherHashes = try CodeSignature.codeDirectoryHashes(ofCodeAt: URL(fileURLWithPath: "/usr/bin/true")) + #expect(Set(ownHashes).isDisjoint(with: otherHashes)) + let result = try validateThisProcess(identifier: identifier, hashes: otherHashes) + #expect( + !result.requirementMatched, + "failureReason \(result.failureReason), \(otherHashes.count) hashes" + ) + } + + @Test("Code that does not exist has no hashes") + func missingCodeThrows() { + let url = URL(fileURLWithPath: "/nonexistent/CodeSignatureTests/missing") + #expect(throws: CodeSignature.Failure.self) { + try CodeSignature.codeDirectoryHashes(ofCodeAt: url) + } + } + + /// Checks this running process against a requirement on the given signing + /// identifier and code directory hashes. + @available(macOS 15.0, *) + private func validateThisProcess(identifier: String, hashes: [Data]) throws -> ValidationResult { + let requirement = try ProcessCodeRequirement.allOf { + SigningIdentifier(identifier) + CodeDirectoryHash.in(hashes) + } + var selfCode: SecCode? + let status = SecCodeCopySelf([], &selfCode) + try #require(status == errSecSuccess, "SecCodeCopySelf failed with status \(status)") + let code = try #require(selfCode) + return SecCodeCheckValidityWithProcessRequirement(code: code, flags: [], requirement: requirement) + } +} From 1dc2ed00f32565df77717017545d6b94a02b0ade Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:51:51 +0000 Subject: [PATCH 17/24] test(02-03): check this process by its audit token, as XPC checks a peer SecCodeCopySelf's code made SecCodeCheckValidityWithProcessRequirement return errSecParam (-50) on the runner. The test now looks the process up by its audit token and logs the compared hashes, the running cdhash and SecTask's verdict when it fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .../CodeSignatureTests.swift | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/Tests/SharedCodeSigningTests/CodeSignatureTests.swift b/Tests/SharedCodeSigningTests/CodeSignatureTests.swift index 5cfd64a0..672ec233 100644 --- a/Tests/SharedCodeSigningTests/CodeSignatureTests.swift +++ b/Tests/SharedCodeSigningTests/CodeSignatureTests.swift @@ -32,11 +32,11 @@ struct CodeSignatureTests { let result = try validateThisProcess(identifier: identifier, hashes: hashes) #expect( result.signatureIsValid, - "failureReason \(result.failureReason), \(hashes.count) hashes, identifier \(identifier)" + "failureReason \(result.failureReason), \(hashes.count) hashes, identifier \(identifier), \(taskValidation(identifier: identifier, hashes: hashes))" ) #expect( result.requirementMatched, - "failureReason \(result.failureReason), \(hashes.count) hashes, identifier \(identifier)" + "failureReason \(result.failureReason), \(hashes.count) hashes, identifier \(identifier), \(taskValidation(identifier: identifier, hashes: hashes))" ) } @@ -82,10 +82,65 @@ struct CodeSignatureTests { SigningIdentifier(identifier) CodeDirectoryHash.in(hashes) } - var selfCode: SecCode? - let status = SecCodeCopySelf([], &selfCode) - try #require(status == errSecSuccess, "SecCodeCopySelf failed with status \(status)") - let code = try #require(selfCode) - return SecCodeCheckValidityWithProcessRequirement(code: code, flags: [], requirement: requirement) + return try SecCodeCheckValidityWithProcessRequirement( + code: codeOfThisProcess(), + flags: [], + requirement: requirement + ) + } + + /// Returns the code of this running process, looked up by its audit token + /// the way the system looks up an XPC peer. + private func codeOfThisProcess() throws -> SecCode { + var token = audit_token_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &token) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { info in + task_info(mach_task_self_, task_flavor_t(TASK_AUDIT_TOKEN), info, &count) + } + } + try #require(result == KERN_SUCCESS, "task_info failed with \(result)") + let tokenData = withUnsafeBytes(of: token) { Data($0) } + let attributes = [kSecGuestAttributeAudit as String: tokenData] as CFDictionary + var code: SecCode? + let status = SecCodeCopyGuestWithAttributes(nil, attributes, [], &code) + try #require(status == errSecSuccess, "SecCodeCopyGuestWithAttributes failed with status \(status)") + return try #require(code) + } + + /// Describes how `SecTaskValidateForRequirement` judges this process + /// against the same requirement, and which hashes were compared, for the + /// log of a failing run. + @available(macOS 15.0, *) + private func taskValidation(identifier: String, hashes: [Data]) -> String { + let hex = { (data: Data) in data.map { String(format: "%02x", $0) }.joined() } + var running = "unknown" + if let code = try? codeOfThisProcess() { + var information: CFDictionary? + let flags = SecCSFlags(rawValue: UInt32(kSecCSDynamicInformation)) + // A running process's code is passed as static code to read its dynamic information. + let staticCode = unsafeBitCast(code, to: SecStaticCode.self) + if SecCodeCopySigningInformation(staticCode, flags, &information) == errSecSuccess, + let unique = (information as NSDictionary?)?[kSecCodeInfoUnique as String] as? Data { + running = hex(unique) + } + } + return "hashes \(hashes.map(hex)), running cdhash \(running), " + taskResult(identifier: identifier, hashes: hashes) + } + + @available(macOS 15.0, *) + private func taskResult(identifier: String, hashes: [Data]) -> String { + do { + let requirement = try ProcessCodeRequirement.allOf { + SigningIdentifier(identifier) + CodeDirectoryHash.in(hashes) + } + guard let task = SecTaskCreateFromSelf(nil) else { + return "SecTaskCreateFromSelf returned nil" + } + return "SecTaskValidateForRequirement returned \(try SecTaskValidateForRequirement(task: task, requirement: requirement))" + } catch { + return "SecTaskValidateForRequirement threw \(error)" + } } } From e83fa770aa944bc2c72fa7b994cef2745db9e0c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:56:50 +0000 Subject: [PATCH 18/24] fix(02-03): accept holzBar's ad hoc build in the menu bar item service - On macOS 26 the listener requires holzBar's signing identifier and one of the code directory hashes of the app it is embedded in when the service has no team (ad hoc), and the same team plus the identifier otherwise - The listener does not listen when that requirement cannot be built (fail closed); the app then looks up source processes itself - The app sets its same-team peer requirement only when it has a team - MenuBarItemService.appIdentifier names the only app the service answers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- MenuBarItemService/Listener.swift | 66 +++++++++++++++++-- Shared/Services/MenuBarItemService.swift | 6 ++ docs/upstream-bugs.md | 2 +- .../MenuBarItemServiceConnection.swift | 11 +++- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/MenuBarItemService/Listener.swift b/MenuBarItemService/Listener.swift index 15f100e4..910d242e 100644 --- a/MenuBarItemService/Listener.swift +++ b/MenuBarItemService/Listener.swift @@ -3,6 +3,8 @@ // MenuBarItemService // +import Foundation +import LightweightCodeRequirements import OSLog import XPC @@ -43,17 +45,50 @@ final class Listener { } /// Activates the listener without checking if it is already active, - /// with the requirement that session peers must be signed with the - /// same team identifier as the service process. + /// with the given requirement that session peers must satisfy. @available(macOS 26.0, *) - private func uncheckedActivateWithSameTeamRequirement() throws { - listener = try XPCListener(service: name, requirement: .isFromSameTeam()) { [weak self] request in + private func uncheckedActivate(requirement: XPCPeerRequirement) throws { + listener = try XPCListener(service: name, requirement: requirement) { [weak self] request in request.accept { message in self?.handleMessage(message) } } } + /// Returns the requirement that session peers must satisfy: holzBar, and + /// no other process. + /// + /// A build signed with a team requires the same team and holzBar's signing + /// identifier. Ad hoc code has no team, and Apple's implicit designated + /// requirement for such code is its code directory hash (cdhash), so an ad + /// hoc build requires the signing identifier and one of the cdhashes of the + /// app this service is embedded in: exactly that app's code is accepted, and + /// every other process is rejected, including ad hoc code that claims the + /// same identifier. This throws when the requirement cannot be built. + @available(macOS 26.0, *) + private func peerRequirement() throws -> XPCPeerRequirement { + if CodeSignature.currentTeamIdentifier != nil { + Logger.default.notice("Listener requires a peer from the same team with the app's signing identifier") + return .isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.appIdentifier) + } + + // The service lives at /Contents/XPCServices/MenuBarItemService.xpc. + let appURL = Bundle.main.bundleURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + guard Bundle(url: appURL)?.bundleIdentifier == MenuBarItemService.appIdentifier else { + throw PeerRequirementError.notEmbeddedInApp + } + let hashes = try CodeSignature.codeDirectoryHashes(ofCodeAt: appURL) + let requirement = try ProcessCodeRequirement.allOf { + SigningIdentifier(MenuBarItemService.appIdentifier) + CodeDirectoryHash.in(hashes) + } + Logger.default.notice("Listener requires the app's exact code (\(hashes.count) code directory hashes)") + return .codeRequirement(requirement) + } + /// Activates the listener without checking if it is already active. private func uncheckedActivate() throws { listener = try XPCListener(service: name) { [weak self] request in @@ -64,6 +99,10 @@ final class Listener { } /// Activates the listener. + /// + /// On macOS 26 and later the listener does not listen at all when its + /// peer requirement cannot be built (fail closed); the app then looks up + /// source processes itself. func activate() { guard listener == nil else { Logger.default.notice("Listener is already active") @@ -74,7 +113,7 @@ final class Listener { do { if #available(macOS 26.0, *) { - try uncheckedActivateWithSameTeamRequirement() + try uncheckedActivate(requirement: peerRequirement()) } else { try uncheckedActivate() } @@ -89,3 +128,20 @@ final class Listener { listener.take()?.cancel() } } + +// MARK: - Listener.PeerRequirementError + +extension Listener { + /// An error that keeps the listener from building its peer requirement. + private enum PeerRequirementError: Error, CustomStringConvertible { + /// The service is not inside holzBar's app bundle. + case notEmbeddedInApp + + var description: String { + switch self { + case .notEmbeddedInApp: + return "The service is not embedded in \(MenuBarItemService.appIdentifier)" + } + } + } +} diff --git a/Shared/Services/MenuBarItemService.swift b/Shared/Services/MenuBarItemService.swift index bf4c16e1..797f7778 100644 --- a/Shared/Services/MenuBarItemService.swift +++ b/Shared/Services/MenuBarItemService.swift @@ -7,6 +7,12 @@ import Foundation enum MenuBarItemService { static let name = "com.holzcloud.holzBar.MenuBarItemService" + + /// The signing identifier of holzBar, the only app the service answers. + /// + /// Xcode signs with the bundle identifier. The build checks that the app's + /// bundle identifier is `name` without `.MenuBarItemService`. + static let appIdentifier = "com.holzcloud.holzBar" } extension MenuBarItemService { diff --git a/docs/upstream-bugs.md b/docs/upstream-bugs.md index ef5bf106..41db6f65 100644 --- a/docs/upstream-bugs.md +++ b/docs/upstream-bugs.md @@ -19,7 +19,7 @@ Issue numbers below refer to [jordanbaird/Ice](https://github.com/jordanbaird/Ic | Hidden section's divider gone after Command-dragging it out | #619 | The divider is put back | | "Hide application menus" only works with the always-hidden section on | #434, #620, #879 | Shown hidden items are no longer dropped from the check | | Menu bar behaviour on displays without a menu bar ("Displays have separate Spaces" off) | #383, #456, #646 | Only the primary display counts as having a menu bar | -| Layout editor or Ice Bar stuck on "Loading menu bar items…" on macOS 26 | #687, #710, #711, #677, #679, #762 and others | The menu bar item service failed to start (`XPCRichError` code 1, seen on 26.7.1). holzBar recognises its own dividers directly and looks up the other items in the app when the service fails | +| Layout editor or Ice Bar stuck on "Loading menu bar items…" on macOS 26 | #687, #710, #711, #677, #679, #762 and others | The menu bar item service failed to start (`XPCRichError` code 1, seen on 26.7.1). holzBar recognises its own dividers directly and looks up the other items in the app when the service fails; the service also accepts holzBar's own ad hoc builds, which have no team identifier, by requiring the exact code of the app it is embedded in (signing identifier and code directory hashes) | ## Open diff --git a/holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift b/holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift index fcd68f98..37bd7069 100644 --- a/holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift +++ b/holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift @@ -145,7 +145,16 @@ extension MenuBarItemService { logger.warning("Session was cancelled with error \(error.localizedDescription)") self.session = nil } - session.setPeerRequirement(.isFromSameTeam()) + // A build signed with a team requires the service to be from the + // same team. An ad hoc build has no team to compare: launchd + // resolves this service name only inside this app's own bundle, + // so no other code can answer, and the service in turn pins this + // app's exact code. LightweightCodeRequirements, which could pin + // the service's code here too, needs macOS 14.4, and the app + // still launches on macOS 14.0. + if CodeSignature.currentTeamIdentifier != nil { + session.setPeerRequirement(.isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.name)) + } session.setTargetQueue(queue) try session.activate() self.session = session From 32b5ccdcee9e89ffd2393ab4656ee2cf438f2e0b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:01:00 +0000 Subject: [PATCH 19/24] docs(02-03): name the framework only where the service imports it The app comment explains why lightweight code requirements are not used there without naming the module, so the module name appears only in MenuBarItemService/Listener.swift among the app and service sources. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .../MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift b/holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift index 37bd7069..52995a80 100644 --- a/holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift +++ b/holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift @@ -149,9 +149,9 @@ extension MenuBarItemService { // same team. An ad hoc build has no team to compare: launchd // resolves this service name only inside this app's own bundle, // so no other code can answer, and the service in turn pins this - // app's exact code. LightweightCodeRequirements, which could pin - // the service's code here too, needs macOS 14.4, and the app - // still launches on macOS 14.0. + // app's exact code. The framework for lightweight code + // requirements, which could pin the service's code here too, + // needs macOS 14.4, and the app still launches on macOS 14.0. if CodeSignature.currentTeamIdentifier != nil { session.setPeerRequirement(.isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.name)) } From 1e6d98b2ace0c2b87b9255a4c8b0076daf865c48 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:06:33 +0000 Subject: [PATCH 20/24] docs(02-03): complete the XPC peer requirement plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .planning/REQUIREMENTS.md | 4 +- .planning/ROADMAP.md | 6 +- .planning/STATE.md | 15 +- .../phases/02-bug-fixes/02-03-SUMMARY.md | 159 ++++++++++++++++++ 4 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 .planning/phases/02-bug-fixes/02-03-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 57f16616..d35536f8 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -35,7 +35,7 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). - [x] **BUG-03**: Spacing relaunch collects the owning apps on macOS 27 too - [ ] **BUG-04**: The event source cache in `MenuBarItemManager` is free of data races - [x] **BUG-05**: The hotkey recorder rejects combinations that macOS 15+ cannot register (Option or Option+Shift only) and tells the user; the hotkey signature stays identical to Ice's -- [ ] **BUG-06**: The XPC menu bar item service accepts the app on ad hoc builds (no team identifier) while still rejecting foreign processes +- [x] **BUG-06**: The XPC menu bar item service accepts the app on ad hoc builds (no team identifier) while still rejecting foreign processes - [x] **BUG-07**: Waiting for a permission twice never leaves a continuation unresumed - [ ] **BUG-08**: Comment and code of the macOS 27 system item allowlist agree @@ -139,7 +139,7 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). | BUG-03 | Phase 2 | Complete | | BUG-04 | Phase 2 | Pending | | BUG-05 | Phase 2 | Complete | -| BUG-06 | Phase 2 | Pending | +| BUG-06 | Phase 2 | Complete | | BUG-07 | Phase 2 | Complete | | BUG-08 | Phase 2 | Pending | | LEFT-01 | Phase 3 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index cff2bdbf..7970625e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -80,12 +80,12 @@ Plans: 4. Waiting for a permission twice never hangs, and the event source cache has no data race 5. The macOS 27 system item allowlist comment and code agree -**Plans**: 2/4 plans executed (sequential waves: one PR branch, every task verified by its CI checks) +**Plans**: 3/4 plans executed (sequential waves: one PR branch, every task verified by its CI checks) Plans: - [x] 02-01-PLAN.md — Tracer: tested `holzBar/Core` package target (`HolzBarCore`); spacing relaunch keeps going past skipped processes (MenuBarAgent skipped on macOS 27), 10 s event-driven quit wait, no force-termination; phase PR opened - [x] 02-02-PLAN.md — Hotkey recorder refuses Option-only combinations on macOS 15+ and says why (signature unchanged); every permission wait returns -- [ ] 02-03-PLAN.md — XPC service accepts holzBar's ad hoc build by pinning the embedding app's signing identifier and code directory hashes (proven by a CodeSignature test suite); foreign processes still rejected +- [x] 02-03-PLAN.md — XPC service accepts holzBar's ad hoc build by pinning the embedding app's signing identifier and code directory hashes (proven by a CodeSignature test suite); foreign processes still rejected - [ ] 02-04-PLAN.md — Lock-guarded event source cache; macOS 27 system item allowlist 0 to 127 with matching comment and tests; PR body complete ### Phase 3: Ice and Sparkle leftovers @@ -198,7 +198,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 |-------|----------------|--------|-----------| | 1. CI and build | 3/3 | Complete (human check: install.sh on a Mac) | 2026-10-02 | | 01.1. Rename to holzBar | 6/6 | In Progress| | -| 2. Bug fixes | 2/4 | In Progress| | +| 2. Bug fixes | 3/4 | In Progress| | | 3. Ice and Sparkle leftovers | 0/0 | Not started | - | | 4. Outdated APIs | 0/0 | Not started | - | | 5. Security and performance | 0/0 | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 6a06c868..1b4ac82f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,16 +4,16 @@ milestone: v0.0.6 current_phase: 1 current_phase_name: CI and build status: verifying -stopped_at: Completed 02-02-PLAN.md -last_updated: "2026-10-02T15:22:42.210Z" +stopped_at: Completed 02-03-PLAN.md +last_updated: "2026-10-02T16:06:25.502Z" last_activity: 2026-10-02 last_activity_desc: Roadmap created (6 phases, 37 requirements mapped) -state_head: b66fe376939c4d2dca91cb8b118528b003182242 +state_head: 32b5ccdcee9e89ffd2393ab4656ee2cf438f2e0b progress: total_phases: 10 completed_phases: 0 total_plans: 13 - completed_plans: 11 + completed_plans: 12 --- # Project State @@ -66,6 +66,7 @@ Progress: [░░░░░░░░░░] 0% | Phase 01.1 P06 | 20min | 2 tasks | 10 files | | Phase 02 P01 | 16min | 2 tasks | 4 files | | Phase 02 P02 | 15min | 2 tasks | 7 files | +| Phase 02 P03 | 20min | 2 tasks | 7 files | ## Accumulated Context @@ -96,6 +97,8 @@ Recent decisions affecting current work: - [Phase 02]: Apps get 10 s (SpacingRelaunch.quitTimeout) to quit and are never force terminated; the wait is KVO-driven and always returns - [Phase 02]: Option-only and Option+Shift-only hotkeys are refused on macOS 15+ in the recorder (alert, recording continues) and in HotkeyRegistry (logged); the Carbon signature stays OSType(1231250720) (D-01) - [Phase 02]: A permission wait returns false when stopCheck() ends it or its task is cancelled; the Grant buttons only reopen the permissions window on true +- [Phase 02]: BUG-06: on ad hoc builds the XPC listener requires SigningIdentifier(com.holzcloud.holzBar) plus CodeDirectoryHash.in(hashes of the embedding app); team builds require same team plus identifier; it fails closed (D-02) +- [Phase 02]: BUG-06: the app sets its same-team peer requirement only when it has a team; LightweightCodeRequirements is imported only in MenuBarItemService/Listener.swift ### Pending Todos @@ -115,6 +118,6 @@ Items acknowledged and deferred at milestone close, most recent first: ## Session Continuity -Last session: 2026-10-02T15:22:42.160Z -Stopped at: Completed 02-02-PLAN.md +Last session: 2026-10-02T16:06:25.451Z +Stopped at: Completed 02-03-PLAN.md Resume file: None diff --git a/.planning/phases/02-bug-fixes/02-03-SUMMARY.md b/.planning/phases/02-bug-fixes/02-03-SUMMARY.md new file mode 100644 index 00000000..7c23799a --- /dev/null +++ b/.planning/phases/02-bug-fixes/02-03-SUMMARY.md @@ -0,0 +1,159 @@ +--- +phase: 02-bug-fixes +plan: 03 +subsystem: xpc-menu-bar-item-service +status: complete +tags: [bug-fix, xpc, code-signing, ad-hoc, macos26, least-privilege, swift-testing] +requires: + - "02-01: test-only Package.swift layout, draft PR #35" + - "02-02: complete (wave order)" +provides: + - "Shared/CodeSigning/CodeSignature.swift: currentTeamIdentifier, currentCodeURL(), signingIdentifier(ofCodeAt:), codeDirectoryHashes(ofCodeAt:) (Security only)" + - "SharedCodeSigning / SharedCodeSigningTests targets in the test-only package (suite \"CodeSignature\", 6 tests)" + - "MenuBarItemService.appIdentifier = \"com.holzcloud.holzBar\"" + - "Listener peer requirement: same team plus identifier, or (ad hoc) the signing identifier plus the embedding app's code directory hashes; fail closed" +affects: + - "Phase 3 (docs/upstream-bugs.md report counts)" + - "Any future team signing: only the branch taken in peerRequirement() / getOrCreateSession() changes" +tech-stack: + added: [] + patterns: + - "LightweightCodeRequirements (macOS 14.4+) only in the service, which runs only on macOS 26; the app and Shared/ use Security only" + - "A running process is checked through SecCodeCopyGuestWithAttributes with its audit token, the way XPC checks a peer" +key-files: + created: + - Shared/CodeSigning/CodeSignature.swift + - Tests/SharedCodeSigningTests/CodeSignatureTests.swift + modified: + - Package.swift + - MenuBarItemService/Listener.swift + - holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift + - Shared/Services/MenuBarItemService.swift + - docs/upstream-bugs.md +decisions: + - "Ad hoc builds: the XPC listener requires SigningIdentifier(com.holzcloud.holzBar) and CodeDirectoryHash.in(hashes of the embedding app), so exactly that app's code is accepted (D-02); team builds require the same team plus the identifier" + - "The listener does not listen when the requirement cannot be built (wrong host bundle, invalid signature, no hashes); the app's in-app lookup takes over (fail closed)" + - "The app sets its same-team peer requirement only when it has a team; on ad hoc builds launchd already resolves the service only inside the app's own bundle" + - "codeDirectoryHashes(ofCodeAt:) validates every architecture slice (kSecCSCheckAllArchitectures) before it returns hashes" +metrics: + duration: 20min + completed: 2026-10-02 + tasks: 2 + files: 7 +estimate: + tokens: 65000 + tasks: 2 +actuals: + tokens: 5950 + tasks: 2 + commits: 4 +plan_head_before: 25e5a8eace0a47b35d5e49ac78f2502b179bcd3e +plan_head_after: 32b5ccdcee9e89ffd2393ab4656ee2cf438f2e0b +--- + +# Phase 2 Plan 03: XPC peer requirement for ad hoc builds Summary + +On macOS 26 the menu bar item service now accepts holzBar's ad hoc builds and still rejects everything else. A build without a team identifier makes the listener require holzBar's signing identifier and one of the code directory hashes (cdhashes) of the app bundle the service is embedded in. A team-signed build requires the same team and the identifier. If the service cannot build its requirement, it does not listen at all. The hashes come from the new `Shared/CodeSigning/CodeSignature.swift`, which uses Security only, so the app still launches on macOS 14.0. The new `CodeSignature` Swift Testing suite proves on the CI runner that these hashes match a running process. + +## Phase PR + +holzcloud/holzBar#35 (still a draft). Its body now lists BUG-06, plus the BUG-05/BUG-07 work from 02-02 that the body was missing. Head after this plan: `32b5ccd` (the docs commit for this SUMMARY comes after it). + +## Why the cdhash requirement (D-02) + +Code signed ad hoc has no team identifier, so `isFromSameTeam()` has nothing to compare. For such code, Apple's implicit designated requirement is its code directory hash. Requiring holzBar's signing identifier plus the cdhashes of the embedding app (a list, because each architecture slice has its own code directory) accepts exactly the code of that app. Every other process is rejected, including other ad hoc code that claims `com.holzcloud.holzBar`. This is the safest option that works with ad hoc signing. No entitlement or permission was added (least privilege), and the new log line names only the kind of requirement and the number of hashes (private). + +## Tasks + +| Task | Name | Commits | +|------|------|---------| +| 1 | Read the code directory hashes that pin a process, proven on the CI runner | `6fb70fd`, `1dc2ed0` | +| 2 | The service accepts holzBar's ad hoc build and still rejects everything else | `e83fa77`, `32b5ccd` | + +## What changed + +- `Shared/CodeSigning/CodeSignature.swift` (new, Foundation and Security only) adds: + - `Failure` (the Security call that failed and its `OSStatus`) + - `currentTeamIdentifier` (read once) + - `currentCodeURL()` + - `signingIdentifier(ofCodeAt:)` + - `codeDirectoryHashes(ofCodeAt:)`. It checks the signature on every slice, then collects `kSecCodeInfoUnique` and `kSecCodeInfoCdHashes` from the default slice and from arm64, arm64e and x86_64. A slice the binary lacks is skipped. Duplicates are removed, and it throws when no hash is found. +- `Package.swift` gains the targets `SharedCodeSigning` (`path: "Shared/CodeSigning"`) and `SharedCodeSigningTests`. Every existing target stays. +- `MenuBarItemService/Listener.swift` imports LightweightCodeRequirements (the only file that does) and adds: + - `uncheckedActivate(requirement:)` + - `peerRequirement()`, which uses the team branch or the ad hoc branch with the host-bundle identifier check and the hashes + - `activate()` logging which requirement is in force; any error leaves the listener inactive +- `MenuBarItemServiceConnection.swift`: `.isFromSameTeam(andMatchesSigningIdentifier: MenuBarItemService.name)` only when `CodeSignature.currentTeamIdentifier != nil`. Otherwise no requirement is set, and a comment explains why. +- `Shared/Services/MenuBarItemService.swift`: `static let appIdentifier = "com.holzcloud.holzBar"`. The `static let name` line is unchanged, so the build's "Check the identifiers" step still reads it. +- `docs/upstream-bugs.md`: the macOS 26 "Loading menu bar items…" row's Fix cell records the ad hoc acceptance. Nothing else in the file changed. + +## Tests that ran (CI, suite "CodeSignature") + +All passed on `1dc2ed0`, `e83fa77` and `32b5ccd` (`Test run with 137 tests in 25 suites passed`): + +- The hashes of this process's code are distinct 20-byte hashes +- This process's code has a signing identifier +- A requirement on identifier and hashes matches this process (macOS 15+) +- Another signing identifier does not match (macOS 15+) +- Another program's hashes do not match (`/usr/bin/true`; also checks that the two hash sets are disjoint) (macOS 15+) +- Code that does not exist has no hashes + +The test process is `swiftpm-testing-helper`, with identifier `swiftpm-testing-helper-`. Identifiers of that form are what the linker gives code it signs ad hoc, so this is probably the same signing kind as holzBar's builds. `CodeSignature` read **2 hashes** for it (from the first, failing run's log). + +## TDD Gate Compliance + +As the plan's Task 1 step 4 prescribes, the test and the implementation went into one commit (`6fb70fd`). There was no separate RED commit with a missing implementation. That commit failed CI, but in the test's harness, not in `CodeSignature` (see below). The fix (`1dc2ed0`) changed only the test's way of getting the running process's code and made the suite green. No test was loosened, skipped or deleted. + +## CI fixes needed + +1. **`SecCodeCopySelf` cannot be used with `SecCodeCheckValidityWithProcessRequirement`** (found in Task 1's first CI run, `6fb70fd`). The call returned `ValidationResult(signatureIsValid: false, requirementMatched: false, failureReason: -50)`, which is errSecParam. The test now looks up its own process with `SecCodeCopyGuestWithAttributes` and its audit token (`task_info(TASK_AUDIT_TOKEN)`), the way XPC identifies a peer. Then the requirement matched. On failure, the expectation comments now also log the compared hashes in hex, the running process's cdhash (`kSecCSDynamicInformation`) and `SecTaskValidateForRequirement`'s verdict. `CodeSignature.swift` itself needed no change. +2. Commit `32b5ccd` reworded a comment in `MenuBarItemServiceConnection.swift` so that the phase verification `git grep LightweightCodeRequirements -- holzBar Shared MenuBarItemService` lists only `MenuBarItemService/Listener.swift`. The Task 2 action had asked for a comment naming the module there. + +There were no compiler warnings in the changed files. The build reported 9 warnings, all of them already present in other files. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Test validated the process through a SecCode that the lightweight check rejects with errSecParam** +- **Found during:** Task 1 (first CI run) +- **Fix:** the process is now looked up by its audit token, and the failure comment carries more detail +- **Files modified:** Tests/SharedCodeSigningTests/CodeSignatureTests.swift +- **Commit:** 1dc2ed0 + +**2. [Rule 1 - Consistency] Comment in the app named the framework, which conflicts with the plan's own verification grep** +- **Found during:** Task 2 verification +- **Fix:** the comment now says "the framework for lightweight code requirements" +- **Files modified:** holzBar/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift +- **Commit:** 32b5ccd + +**3. [Addition] `codeDirectoryHashes(ofCodeAt:)` validates with `kSecCSCheckAllArchitectures`** +- Without this flag, the default check covers only one slice, while hashes are collected from all slices. Now every pinned hash belongs to a slice that was validated. + +The former-name rule (added after planning) was respected: the former name does not appear in any changed file outside `.planning/`. The `former-name` job passed on every head. + +## Open human checks + +On the Mac (macOS 26.7.1), with holzBar built by CI or `Scripts/install.sh` (ad hoc; `codesign -dv` shows `TeamIdentifier=not set`): + +1. Run `log stream --level info --predicate 'process == "MenuBarItemService" OR subsystem BEGINSWITH "com.holzcloud.holzBar"'`. +2. Launch holzBar and open Settings, Menu Bar Layout. + +Expected: +- The service logs "Listener requires the app's exact code (N code directory hashes)" with N ≥ 1. +- The layout shows the menu bar items instead of "Loading menu bar items…". +- No "looking up source processes in the app instead" line appears. + +## Known Stubs + +None. + +## Threat Flags + +None beyond the plan's threat model (T-02-07 to T-02-10 mitigated or accepted as planned; T-02-SC: only local targets were added, with no package dependency). + +## Self-Check: PASSED + +- FOUND: Shared/CodeSigning/CodeSignature.swift, Tests/SharedCodeSigningTests/CodeSignatureTests.swift +- FOUND commits: 6fb70fd, 1dc2ed0, e83fa77, 32b5ccd (on origin/claude/ice-fork-development-hzdl1d) +- PR #35 head = 32b5ccd: build, test, swiftlint and former-name passed. Logs show `BUILD SUCCEEDED`, `Test run with 137 tests in 25 suites passed`, `Suite "CodeSignature" passed` and `Done linting! Found 0 violations`. From 14a6dd4bb1ff295edd7b280e3830d56305d1952a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:11:09 +0000 Subject: [PATCH 21/24] fix(02-04): guard the event source cache with a lock - getEventSource(with:) looks up, creates and stores its sources inside one OSAllocatedUnfairLock (withLockUnchecked, as CGEventSource is not Sendable) - concurrent moves and clicks can no longer race on the static cache (BUG-04) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .../MenuBarItems/MenuBarItemManager.swift | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift b/holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift index a9f272da..d4283dbc 100644 --- a/holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -5,6 +5,7 @@ import Cocoa import Combine +import os import OSLog import Semaphore @@ -729,20 +730,26 @@ extension MenuBarItemManager { } /// Returns an event source for a menu bar item event operation. + /// + /// Moves and clicks call this concurrently from nonisolated code, so the cache + /// is guarded by a lock. The unchecked lock API is used because `CGEventSource` + /// is not Sendable. private nonisolated func getEventSource( with stateID: CGEventSourceStateID = .hidSystemState ) throws -> CGEventSource { enum Context { - static var cache = [CGEventSourceStateID: CGEventSource]() + static let sources = OSAllocatedUnfairLock(uncheckedState: [CGEventSourceStateID: CGEventSource]()) } - if let source = Context.cache[stateID] { + return try Context.sources.withLockUnchecked { sources in + if let source = sources[stateID] { + return source + } + guard let source = CGEventSource(stateID: stateID) else { + throw EventError.invalidEventSource + } + sources[stateID] = source return source } - guard let source = CGEventSource(stateID: stateID) else { - throw EventError.invalidEventSource - } - Context.cache[stateID] = source - return source } /// Prevents local events from being suppressed. From d584f45c293eaef322a27883640e2cbeec0db7a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:11:23 +0000 Subject: [PATCH 22/24] test(02-04): pin the macOS 27 system item allowlist to the measured range - every drawn item (0, 2, 6, 8) is allowed - the allowlist is exactly 0 through 127, the highest measured number - nothing beyond the measurement is offered Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .../SystemItems27Tests.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift diff --git a/Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift b/Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift new file mode 100644 index 00000000..da09aea5 --- /dev/null +++ b/Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift @@ -0,0 +1,26 @@ +import Testing +@testable import HolzBarMacOS27Core + +@Suite("SystemItems27") +struct SystemItems27Tests { + @Test("Every drawn system item is allowed") + func drawnItemsAreAllowed() { + for number in SystemItems27.drawn { + #expect(SystemItems27.allowed.contains(number)) + } + } + + @Test("The allowlist is the measured range, 0 through 127") + func allowedIsTheMeasuredRange() { + #expect(SystemItems27.highestMeasured == 127) + #expect(SystemItems27.allowed.lowerBound == 0) + #expect(SystemItems27.allowed.upperBound == SystemItems27.highestMeasured) + #expect(SystemItems27.allowed.count == 128) + } + + @Test("Nothing beyond the measurement is allowed") + func nothingBeyondTheMeasurementIsAllowed() { + #expect(!SystemItems27.allowed.contains(SystemItems27.highestMeasured + 1)) + #expect(!SystemItems27.allowed.contains(-1)) + } +} From af48d9aad9173cc07b401acf9d469091728c89d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:11:42 +0000 Subject: [PATCH 23/24] fix(02-04): allow the measured system item range on macOS 27 - SystemItems27 (Core) holds the drawn numbers, the highest measured number (127) and the allowlist derived from them, 0 through 127 - MenuBarAssessmentAssertion27 builds its configuration from SystemItems27.allowed; its comment now says what the code does and how jordanbaird/Ice#1001's 0 to 63 relates, instead of claiming they match (BUG-08) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .../MenuBar/MacOS27/Core/SystemItems27.swift | 23 +++++++++++++++++++ .../MenuBarAssessmentAssertion27.swift | 14 +++++------ 2 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 holzBar/MenuBar/MacOS27/Core/SystemItems27.swift diff --git a/holzBar/MenuBar/MacOS27/Core/SystemItems27.swift b/holzBar/MenuBar/MacOS27/Core/SystemItems27.swift new file mode 100644 index 00000000..61e75e58 --- /dev/null +++ b/holzBar/MenuBar/MacOS27/Core/SystemItems27.swift @@ -0,0 +1,23 @@ +// +// SystemItems27.swift +// holzBar +// + +import Foundation + +/// MenuBarAgent's numbered system items on macOS 27. +/// +/// Measured with `Scripts/macos27/system-item-probe.swift` on macOS 27.0 (2026-09-29). +enum SystemItems27 { + /// The numbers that draw an item on macOS 27.0: 0 is the battery, 2 the clock, + /// 6 Wi-Fi and 8 Control Centre. + static let drawn: Set = [0, 2, 6, 8] + + /// The highest number measured: MenuBarAgent accepted every number from 0 to this one, + /// offered one at a time and all together in one live assertion. + static let highestMeasured = 127 + + /// The numbers holzBar keeps on the bar: all measured ones, so a system item that a later + /// build numbers above 8 stays visible. holzBar hides applications' items, not the system's. + static let allowed: ClosedRange = 0...highestMeasured +} diff --git a/holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift b/holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift index 1be2093f..464d3dbb 100644 --- a/holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift +++ b/holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift @@ -61,12 +61,12 @@ final class MenuBarAssessmentAssertion27: ConcealmentBackend27 { /// The system items holzBar keeps on the bar. /// - /// MenuBarAgent numbers them, and on macOS 27.0 only five numbers draw anything: 0 is the - /// battery, 2 the clock, 6 Wi-Fi and 8 Control Centre; 1, 3, 4, 5 and 7 draw nothing, and so - /// does every number above 8. All of them are accepted, though, up to 127 at least, so the - /// range is wider than what this build of macOS draws: a system item added by a later build - /// would otherwise be concealed, and holzBar hides applications' items, not the system's. The - /// range matches the one @carlossantos74 arrived at in jordanbaird/Ice#1001. + /// MenuBarAgent numbers its system items, and on macOS 27.0 only 0 (battery), 2 (clock), + /// 6 (Wi-Fi) and 8 (Control Centre) draw anything. It accepted every number up to 127, + /// offered one at a time and all at once. holzBar keeps that whole measured range + /// (`SystemItems27`), so a system item added by a later build is not concealed: holzBar hides + /// applications' items, not the system's. jordanbaird/Ice#1001 (@carlossantos74) keeps 0 to + /// 63, which lies inside it. /// /// Control Centre's capture indicator — the green camera button, orange for the microphone, /// indigo for screen sharing — is not one of these numbers and cannot be kept. It is drawn @@ -74,7 +74,7 @@ final class MenuBarAssessmentAssertion27: ConcealmentBackend27 { /// number to 127, Control Centre's bundle identifier, the capturing application's own. The /// small green dot beside the clock is not an item and stays either way. Measured with /// `Scripts/macos27/system-item-probe.swift` on macOS 27.0 (2026-09-29). - private static let systemItems = (0...63).map { NSNumber(value: $0) } as NSArray + private static let systemItems = SystemItems27.allowed.map { NSNumber(value: $0) } as NSArray private static let classes: (configuration: AnyClass, assertion: AnyClass)? = { guard From 799e386ddf422e7f44a6ea350611b8ffd2a8702c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:19:20 +0000 Subject: [PATCH 24/24] docs(02-04): complete the event source lock and allowlist plan - 02-04-SUMMARY.md with the allowlist decision (D-03), CI result and the phase's open human checks - STATE, ROADMAP and REQUIREMENTS (BUG-04, BUG-08 complete) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSzFQk1ZzGXFVBDZMYq8eu --- .planning/REQUIREMENTS.md | 8 +- .planning/ROADMAP.md | 6 +- .planning/STATE.md | 15 ++- .../phases/02-bug-fixes/02-04-SUMMARY.md | 110 ++++++++++++++++++ 4 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 .planning/phases/02-bug-fixes/02-04-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index d35536f8..32136817 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -33,11 +33,11 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). - [x] **BUG-01**: Applying menu bar item spacing relaunches every affected app (`continue` instead of `break`) - [x] **BUG-02**: Spacing relaunch waits long enough for apps to quit, does not force-terminate them after 1 s, and always resumes its continuation - [x] **BUG-03**: Spacing relaunch collects the owning apps on macOS 27 too -- [ ] **BUG-04**: The event source cache in `MenuBarItemManager` is free of data races +- [x] **BUG-04**: The event source cache in `MenuBarItemManager` is free of data races - [x] **BUG-05**: The hotkey recorder rejects combinations that macOS 15+ cannot register (Option or Option+Shift only) and tells the user; the hotkey signature stays identical to Ice's - [x] **BUG-06**: The XPC menu bar item service accepts the app on ad hoc builds (no team identifier) while still rejecting foreign processes - [x] **BUG-07**: Waiting for a permission twice never leaves a continuation unresumed -- [ ] **BUG-08**: Comment and code of the macOS 27 system item allowlist agree +- [x] **BUG-08**: Comment and code of the macOS 27 system item allowlist agree ### Leftovers @@ -137,11 +137,11 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). | BUG-01 | Phase 2 | Complete | | BUG-02 | Phase 2 | Complete | | BUG-03 | Phase 2 | Complete | -| BUG-04 | Phase 2 | Pending | +| BUG-04 | Phase 2 | Complete | | BUG-05 | Phase 2 | Complete | | BUG-06 | Phase 2 | Complete | | BUG-07 | Phase 2 | Complete | -| BUG-08 | Phase 2 | Pending | +| BUG-08 | Phase 2 | Complete | | LEFT-01 | Phase 3 | Pending | | LEFT-02 | Phase 3 | Pending | | LEFT-03 | Phase 3 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7970625e..3ad9d6d3 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -80,13 +80,13 @@ Plans: 4. Waiting for a permission twice never hangs, and the event source cache has no data race 5. The macOS 27 system item allowlist comment and code agree -**Plans**: 3/4 plans executed (sequential waves: one PR branch, every task verified by its CI checks) +**Plans**: 4/4 plans executed (sequential waves: one PR branch, every task verified by its CI checks) Plans: - [x] 02-01-PLAN.md — Tracer: tested `holzBar/Core` package target (`HolzBarCore`); spacing relaunch keeps going past skipped processes (MenuBarAgent skipped on macOS 27), 10 s event-driven quit wait, no force-termination; phase PR opened - [x] 02-02-PLAN.md — Hotkey recorder refuses Option-only combinations on macOS 15+ and says why (signature unchanged); every permission wait returns - [x] 02-03-PLAN.md — XPC service accepts holzBar's ad hoc build by pinning the embedding app's signing identifier and code directory hashes (proven by a CodeSignature test suite); foreign processes still rejected -- [ ] 02-04-PLAN.md — Lock-guarded event source cache; macOS 27 system item allowlist 0 to 127 with matching comment and tests; PR body complete +- [x] 02-04-PLAN.md — Lock-guarded event source cache; macOS 27 system item allowlist 0 to 127 with matching comment and tests; PR body complete ### Phase 3: Ice and Sparkle leftovers @@ -198,7 +198,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 |-------|----------------|--------|-----------| | 1. CI and build | 3/3 | Complete (human check: install.sh on a Mac) | 2026-10-02 | | 01.1. Rename to holzBar | 6/6 | In Progress| | -| 2. Bug fixes | 3/4 | In Progress| | +| 2. Bug fixes | 4/4 | In Progress| | | 3. Ice and Sparkle leftovers | 0/0 | Not started | - | | 4. Outdated APIs | 0/0 | Not started | - | | 5. Security and performance | 0/0 | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 1b4ac82f..4dab5285 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,16 +4,16 @@ milestone: v0.0.6 current_phase: 1 current_phase_name: CI and build status: verifying -stopped_at: Completed 02-03-PLAN.md -last_updated: "2026-10-02T16:06:25.502Z" +stopped_at: Completed 02-04-PLAN.md +last_updated: "2026-10-02T16:19:14.704Z" last_activity: 2026-10-02 last_activity_desc: Roadmap created (6 phases, 37 requirements mapped) -state_head: 32b5ccdcee9e89ffd2393ab4656ee2cf438f2e0b +state_head: af48d9aad9173cc07b401acf9d469091728c89d2 progress: total_phases: 10 completed_phases: 0 total_plans: 13 - completed_plans: 12 + completed_plans: 13 --- # Project State @@ -67,6 +67,7 @@ Progress: [░░░░░░░░░░] 0% | Phase 02 P01 | 16min | 2 tasks | 4 files | | Phase 02 P02 | 15min | 2 tasks | 7 files | | Phase 02 P03 | 20min | 2 tasks | 7 files | +| Phase 02 P04 | 12min | 2 tasks | 4 files | ## Accumulated Context @@ -99,6 +100,8 @@ Recent decisions affecting current work: - [Phase 02]: A permission wait returns false when stopCheck() ends it or its task is cancelled; the Grant buttons only reopen the permissions window on true - [Phase 02]: BUG-06: on ad hoc builds the XPC listener requires SigningIdentifier(com.holzcloud.holzBar) plus CodeDirectoryHash.in(hashes of the embedding app); team builds require same team plus identifier; it fails closed (D-02) - [Phase 02]: BUG-06: the app sets its same-team peer requirement only when it has a team; LightweightCodeRequirements is imported only in MenuBarItemService/Listener.swift +- [Phase 02]: BUG-08 (D-03): macOS 27 system item allowlist is 0 through 127, the measured range, held in SystemItems27; 63 only matched jordanbaird/Ice#1001 +- [Phase 02]: BUG-04: event source cache guarded by one OSAllocatedUnfairLock (withLockUnchecked; CGEventSource is not Sendable) ### Pending Todos @@ -118,6 +121,6 @@ Items acknowledged and deferred at milestone close, most recent first: ## Session Continuity -Last session: 2026-10-02T16:06:25.451Z -Stopped at: Completed 02-03-PLAN.md +Last session: 2026-10-02T16:19:14.654Z +Stopped at: Completed 02-04-PLAN.md Resume file: None diff --git a/.planning/phases/02-bug-fixes/02-04-SUMMARY.md b/.planning/phases/02-bug-fixes/02-04-SUMMARY.md new file mode 100644 index 00000000..a7373367 --- /dev/null +++ b/.planning/phases/02-bug-fixes/02-04-SUMMARY.md @@ -0,0 +1,110 @@ +--- +phase: 02-bug-fixes +plan: 04 +subsystem: menu-bar-items-and-macos27-concealment +status: complete +tags: [bug-fix, concurrency, data-race, macos27, allowlist, swift-testing] +requires: + - "02-03: complete (wave order), draft PR #35" +provides: + - "MenuBarItemManager.getEventSource(with:): cache guarded by one OSAllocatedUnfairLock" + - "holzBar/MenuBar/MacOS27/Core/SystemItems27.swift: drawn, highestMeasured (127), allowed (0...127)" + - "Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift (suite \"SystemItems27\", 3 tests)" + - "Final PR body of #35 covering BUG-01 to BUG-08 and the open human checks" +affects: + - "MenuBarAssessmentAssertion27 (configuration built from SystemItems27.allowed)" + - "Phase 2 verification and PR merge (orchestrator)" +tech-stack: + added: [] + patterns: + - "Function-local enum holding a static OSAllocatedUnfairLock(uncheckedState:) for non-Sendable cached state (macOS 14 target, Mutex needs macOS 15)" + - "Measured macOS 27 constants live in a pure Core file and are pinned by Swift Testing" +key-files: + created: + - holzBar/MenuBar/MacOS27/Core/SystemItems27.swift + - Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift + modified: + - holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift + - holzBar/MenuBar/MacOS27/MenuBarAssessmentAssertion27.swift +decisions: + - "BUG-08 (D-03): the macOS 27 system item allowlist is 0 through 127, the widest range MenuBarAgent was measured to accept (one at a time and all 128 at once in one live assertion); 63 had been chosen only to match jordanbaird/Ice#1001 and avoid a merge conflict" + - "BUG-04: lookup, creation and store of event sources happen under one OSAllocatedUnfairLock via withLockUnchecked, because CGEventSource is not Sendable; callers and signature unchanged" +metrics: + duration: 12min + completed: 2026-10-02 +actuals: + tokens: 1600 + tasks: 2 + commits: 3 +plan_head_before: 1e6d98b2ace0c2b87b9255a4c8b0076daf865c48 +plan_head_after: af48d9aad9173cc07b401acf9d469091728c89d2 +--- + +# Phase 2 Plan 04: Event source cache lock and measured macOS 27 allowlist Summary + +The event source cache in `MenuBarItemManager` is now read and filled only inside one `OSAllocatedUnfairLock` (BUG-04), and the macOS 27 system item allowlist is the measured 0 through 127, held once in the Core file `SystemItems27.swift`, pinned by Swift Testing and described truthfully in the assertion's comment (BUG-08). PR #35's body now covers all of Phase 2. + +## What was done + +### Task 1: the event source cache is guarded by a lock (BUG-04) — commit 14a6dd4 + +- `getEventSource(with:)` keeps its function-local `enum Context`, now with `static let sources = OSAllocatedUnfairLock(uncheckedState: [CGEventSourceStateID: CGEventSource]())`. +- The lookup, `CGEventSource(stateID:)` creation (throwing `EventError.invalidEventSource` on failure) and the store run inside one `Context.sources.withLockUnchecked { ... }`; the closure rethrows, so the signature and the three callers (`permitLocalEvents()` and two move/click paths) are unchanged. +- The doc comment says why: moves and clicks call it concurrently from nonisolated code, and the unchecked API is used because `CGEventSource` is not Sendable. +- `import os` added above `import OSLog`. +- CI on 14a6dd4: build, test (137 tests in 25 suites) and swiftlint (0 violations) green, no warning in `MenuBarItemManager.swift`. + +### Task 2: the allowlist is the measured range (BUG-08) — commits d584f45 (test), af48d9a (fix) + +- New `holzBar/MenuBar/MacOS27/Core/SystemItems27.swift` (Foundation only, no `@available`): `drawn: Set = [0, 2, 6, 8]`, `highestMeasured = 127`, `allowed: ClosedRange = 0...highestMeasured`. +- New `Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift`, suite "SystemItems27": "Every drawn system item is allowed", "The allowlist is the measured range, 0 through 127", "Nothing beyond the measurement is allowed". +- `MenuBarAssessmentAssertion27.systemItems` is built from `SystemItems27.allowed` (each number as `NSNumber`, as an `NSArray`). The first paragraph of its doc comment now states the drawn numbers, the measured accepted range (up to 127, one at a time and all at once), why holzBar keeps it whole, and that jordanbaird/Ice#1001 (@carlossantos74) keeps 0 to 63, which lies inside it. The sentence claiming the ranges match is gone; the capture-indicator paragraph and the measurement line are unchanged. +- CI on af48d9a: build (BUILD SUCCEEDED), test (140 tests in 26 suites passed, including SystemItems27, SpacingRelaunch, Modifiers, CodeSignature), swiftlint (0 violations in 133 files), former-name and cask green; no warning in the changed files. +- PR #35 body updated through `gh api -X PATCH`: completion paragraph, one bullet per BUG-01 to BUG-08 plus the cleanup, a "Verified by" line, all open human checks, attribution. The PR is still a draft, base `main`, mergeable state `clean`. + +## Allowlist decision and evidence (D-03) + +- `MenuBarAssessmentAssertion27.swift` (before): on macOS 27.0 only 0 (battery), 2 (clock), 6 (Wi-Fi) and 8 (Control Centre) draw; "All of them are accepted, though, up to 127 at least"; the capture-indicator test held a live assertion with every number to 127. +- `Scripts/macos27/system-item-probe.swift`: measured on macOS 27.0 (2026-09-29) with every number from 0 to 127 offered one at a time; MenuBarAgent accepts them all and draws five; a live assertion held all 128 numbers. +- History: a0c27a7 allowed 0...8; d1858fb widened to 0...31; 15f59a9 set 63 only to match jordanbaird/Ice#1001 so the branches would not collide ("the difference is only a difference"). +- `.planning/codebase/CONCERNS.md` (Scaling Limits): a system item numbered above 63 in a later macOS 27.x would be concealed; widen to the measured 127. +- Decision: 0 through 127 — the widest measured range, matching the comment's own reason (never conceal a system item a later build adds). Reversible: one constant. + +## Final state + +- PR: holzcloud/holzBar#35 (draft), head af48d9aad9173cc07b401acf9d469091728c89d2 for the code; the docs commit of this SUMMARY follows on the same branch. +- Tests: 140 tests in 26 suites pass in CI. + +## TDD Gate Compliance + +- RED: `test(02-04)` commit d584f45 adds the suite before `SystemItems27` exists, so it cannot compile without the GREEN commit. It was not pushed on its own: there is no compiler here, and the plan type is `execute`, not `tdd`, so the plan-level RED-evidence gate does not apply. RED evidence is therefore by construction (missing symbol), not a CI run. +- GREEN: `fix(02-04)` commit af48d9a; all 3 SystemItems27 tests pass in CI. +- REFACTOR: not needed. + +## Deviations from Plan + +None - plan executed exactly as written. (Task 2's test and fix are two commits, test first, following the task's `tdd="true"`.) + +## Open human checks (whole phase) + +- macOS 26.7.1: with several apps that have menu bar items running, change "Menu bar item spacing" and press Apply. Every app with a menu bar item quits and reopens, including when Control Center or holzBar would have come first. Repeat once: the same set relaunches. (02-01) +- macOS 26.7.1: run an app that asks before quitting (leave the question unanswered) and apply spacing. The app is not killed; after about 10 s holzBar shows "did not quit within 10 seconds and were not restarted" naming it; the other apps relaunch. (02-01) +- macOS 27: apply spacing. No alert names MenuBarAgent, and the apps with menu bar items relaunch. Note whether the spacing changes; if not, open a follow-up. (02-01) +- macOS 26.7.1, Settings, Hotkeys: Option-H shows "macOS does not allow this hotkey" (Command or Control needed), the recorder still records after OK; Option-Shift-H the same; Command-Option-H records and fires from another app. Hotkeys set before the update still work. (02-02) +- Permissions window with Screen Recording reset (`tccutil reset ScreenCapture com.holzcloud.holzBar`): Grant, Reset and Grant Again, grant in System Settings: the window comes to the front once and shows it granted. Reset again, Grant, do not grant, Continue: the window does not reopen by itself. (02-02) +- macOS 26.7.1 ad hoc build: with `log stream --level info --predicate 'process == "MenuBarItemService" OR subsystem BEGINSWITH "com.holzcloud.holzBar"'`, launch holzBar and open Settings, Menu Bar Layout: the service logs "Listener requires the app's exact code (N code directory hashes)" with N ≥ 1, the layout shows the items, and no "looking up source processes in the app instead" line appears. (02-03) +- macOS 26.7.1, Settings, Menu Bar Layout: drag several items between sections in quick succession and click hidden items in the holzBar Shelf while a move runs. Items move and open as before; no crash. (02-04, BUG-04) +- macOS 27: hide a few apps' items. Battery, clock, Wi-Fi and Control Centre stay on the bar while the hidden apps' items disappear. (02-04, BUG-08) + +## Known Stubs + +None. + +## Threat Flags + +None. T-02-11 is mitigated as planned (one lock around lookup, creation and store; no unguarded static state remains). T-02-12 accepted: only MenuBarAgent's own system items stay visible. T-02-SC: no package or dependency change; the new Core file joins the existing `HolzBarMacOS27Core` target. + +## Self-Check: PASSED + +- FOUND: holzBar/MenuBar/MacOS27/Core/SystemItems27.swift, Tests/HolzBarMacOS27CoreTests/SystemItems27Tests.swift +- FOUND commits: 14a6dd4, d584f45, af48d9a