Skip to content

Latest commit

 

History

History
414 lines (398 loc) · 30.3 KB

File metadata and controls

414 lines (398 loc) · 30.3 KB

CloseUp — Agent Instructions

CloseUp is an open-source macOS app that overlays window controls (close / minimize / zoom / hide / quit) onto the native macOS Mission Control, adds keyboard control, and is fully localized. Menu-bar (accessory) app, Developer-ID distribution, Sparkle self-update, GPL-3.0.

This file is authoritative. CLAUDE.md is a symlink to it. When a convention is decided or a hard-won bug class is found, write it here immediately as a hard rule.

Working agreement

  • Phases end test-green: build with zero warnings + tests pass before moving on. Red→green TDD for logic in CloseUpKit.
  • Spec-first, verify reality: research unknowns against official docs and shipped source before building; flag deviations from the spec with evidence.

Architecture

  • Two targets (project.yml, XcodeGen — the generated .xcodeproj is a disposable artifact, gitignored, regenerated by make gen):
    • CloseUpKit (Sources/CloseUpKit) — static library, all testable logic, no UI deps. System facilities (AX, CGWindowList, workspace) sit behind protocols returning semantic values so the logic is unit-testable with mocks.
    • CloseUp (Sources/CloseUp) — thin app shell: SwiftUI/AppKit + Sparkle + KeyboardShortcuts + PermissionFlow.
    • CloseUpTests (Tests/CloseUpKitTests) — hostless unit tests + i18n guards.
  • Command interface (make): gen / build / run / test / archive / dmg / clean + update-test-*. Humans, agents, and CI use these identically.
  • Dev vs release identity: Debug is com.oomol.CloseUp.dev / "CloseUp Dev", ad-hoc signed, no hardened runtime — a different app to the OS so a local build never collides with the installed release (login item, defaults). Ad-hoc re-hashes every build, so the Accessibility TCC grant is orphaned after each make build — painful when iterating on the overlay (which needs AX). Opt in to make dev-cert (creates a per-machine self-signed identity in the login keychain; make build auto-detects it via find-identity — no -v, the cert is an untrusted root — and forces CODE_SIGN_STYLE=Manual so the override doesn't make the SPM deps demand a team) and the grant survives rebuilds. Nothing secret is committed; a fresh clone builds ad-hoc with zero setup. A missing AX grant fails SILENTLY and misleadingly: the CGEvent tap can't be created so no click is ever handled (overlay buttons dead, click-to-dismiss dead), yet the lights still render via the no-AX fallback below — so it looks like a code bug, not a permission one. Tell from the log: tap create failed (Accessibility not granted?) and observer armed (… trusted=n). A grant bound to a prior (ad-hoc) signature is orphaned when you switch to the dev-cert, and toggling the stale Accessibility row won't re-bind it — tccutil reset Accessibility com.oomol.CloseUp.dev, then re-grant once (it persists across dev-cert rebuilds thereafter). A make build run from a SANDBOXED context (e.g. some sub-agents' Bash) can't read the login keychain, so security find-identity returns nothing and the Makefile SILENTLY falls back to AD-HOC signing — which re-hashes the binary and orphans the grant (trusted=n, tap create failed) even though you have the dev-cert. So when the AX grant must survive (any click/button/tap verification), do the dev-cert build + relaunch from the MAIN shell (non-sandboxed) — there find-identity sees the cert and the build re-matches the existing grant (trusted=y, no re-grant). Confirm with codesign -dvv <app>: it must show Authority=CloseUp Dev Self-Signed, never Signature=adhoc. log show --debug does NOT see the hot-path .debug lines (hover, overlay show, MC state) — they are not persisted to the store, so log show returns nothing for them even with --debug; only .notice+ lines (session begin/end, layout settled, observer armed) come back. This trap reads as "the overlay never fires" while a screenshot proves it does. To observe the hot-path events during a repro you MUST capture a live /usr/bin/log stream … --debug (see the diagnostics rule below) — log show post-hoc is fine only for the .notice lifecycle lines.
  • Per-architecture binaries (arm64 + x86_64), macOS 14.0 floor — CloseUp ships one app PER ARCHITECTURE, never a universal binary, valuing a smaller per-CPU download. A post-build phase thins the prebuilt fat Sparkle xcframework to the built arch (scripts/thin-embedded-frameworks.sh, wired in project.yml; CI's export step asserts the main binary AND embedded Sparkle are single-slice). Each arch is its own product line with its own symmetric Sparkle feed — appcast-arm64.xml / appcast-x86_64.xml, each pinned at COMPILE time in UpdaterDelegate.feedURLString(for:) via #if arch (both return their own explicit URL — no Info.plist fallback) so no CI misconfig can cross-wire feeds, and cross-arch updates are unsupported by design. There is NO backward-compat with the universal 0.1.0: it baked SUFeedURL=appcast.xml and the per-arch feeds are new files, so 0.1.0 is an intentional orphan (cannot auto-update) — do not re-introduce a compatibility shim. project.yml pins ARCHS: arm64 for local builds; CI overrides per pass (ARCHS=x86_64 ONLY_ACTIVE_ARCH=NO), and make build ARCH=x86_64 cross-builds the Intel app locally. Reach newer APIs only behind #available with a fallback (see DS.dsGlass*ButtonStyle). generate_appcast derives the output appcast FILENAME from the app's SUFeedURL basename (it can't run the app, so it reads Info.plist), so Info.plist's SUFeedURL MUST stay …/appcast.xml. The CI's LOCAL working file in every build/dist-$ARCH is appcast.xml (the arch already lives in the directory name); the per-arch feed name is applied ONLY at the gh-pages boundary — seed from origin/gh-pages:appcast-$ARCH.xml into dist-$ARCH/appcast.xml, then publish dist-$ARCH/appcast.xml back to appcast-$ARCH.xml. This mirrors LockIME. If SUFeedURL is ever changed to appcast-arm64.xml (as #3 did), generate_appcast writes appcast-arm64.xml in EVERY dist dir, so the local test -f .../appcast.xml / publish cp miss the file → "appcast step exits 1 right after Wrote 1 new update" (the log line …in appcast-arm64.xml is the DERIVED name from the basename, NOT a per-arch feed). SUFeedURL is a runtime no-op (both arches pin their feed explicitly in feedURLString), so it is free to carry the canonical appcast.xml basename. Do NOT use -o to force a per-arch local name — and do NOT, like LockIME, let arm64's feed BE appcast.xml: LockIME's legacy was arm64-only, but CloseUp's universal 0.1.0 also runs on Intel, so the feeds stay symmetric (appcast-arm64.xml / appcast-x86_64.xml) and appcast.xml is a frozen 0.1.0 orphan that is never written.
  • Non-sandboxed, Hardened Runtime. The Accessibility / CGEvent-tap / private API path the core feature needs cannot run in the App Sandbox — the entitlements file is intentionally empty.

Core feature — load-bearing platform facts

The Mission Control overlay relies on undocumented/private APIs (all widely used in notarized Developer-ID apps; they bar Mac App Store distribution, which is out of scope). Reference blueprints: established GPL-3.0 Developer-ID apps that drive Mission Control over this same private-API path.

  • Mission Control is drawn by the Dock (com.apple.dock). Detect open with an AXObserver on the Dock pid for AXExposeShowAllWindows / AXExposeShowFrontWindows.
  • Do NOT use AXExposeExit to detect close — it is unreliable. A 3-finger trackpad swipe fires AXExposeExit while Mission Control stays open (even a swipe that changes no Space), so tearing the session down on it makes the lights vanish on every window for the rest of the MC session until MC is reopened. Detect close by polling a reliable signal: the Dock owns an exposé surface at window layer 18 (kCGWindowLayer == 18) only while MC is visible — match it by the Dock pid (kCGWindowOwnerPID; the owner name is localized, e.g. "程序坞"), debounce a few misses, then endSession. (Polling beats the notifications: the AX expose events are unreliable under trackpad swipes, so a notification-driven design — which mirrors our old approach — has the same latent bug the layer-18 poll avoids.)
  • The Dock observer must self-heal — it is NOT fire-and-forget. Two hard-won failure modes silently kill the overlay on every desktop until relaunch: (a) the AXObserver binds to the Dock pid captured at arm time, so a Dock relaunch/crash leaves it bound to a dead pid forever; (b) it stops delivering after a Space transition into a full-screen app. So re-arm (stop + re-start, which re-reads the live Dock pid): on activeSpaceDidChange (the full-screen-wedge case), and via a ~2 s pid-diff health poll for a Dock relaunch. Do NOT use NSWorkspace.didLaunchApplication/didTerminate to detect the Dock — verified: those never fire for com.apple.dock (it's an agent/LSUIElement), so a pid diff (MissionControlObserver.armedDockPID vs currentDockPID()) is the only reliable signal. Keep the space observer + poll alive for the whole engine lifetime, not just per-session. Never assume the Dock pid is stable.
  • Reconcile open/close idempotently; never de-dup the edges. The Dock coalesces/drops the expose notifications (a missed AXExposeExit across a Space switch must not be able to wedge every later open), and re-arming re-fires an open. The lifecycle is driven by the layer-18 poll gated on a single sessionActive bool → begin / resync / end; a repeat-open re-syncs, never stacks a second set of timers/tap. (The old MissionControlSession reducer is gone — the poll + bool is the authority.)
  • Diagnose live via Log (os.Logger, subsystem com.oomol.CloseUp) — capture with /usr/bin/log BY FULL PATH. In zsh, bare log is a SHELL BUILTIN (the csh-compat watch-list printer), so log stream … dies with too many arguments and silently captures zero lines — the real cause of the "app emits no logs" trap (it is NOT word-splitting). Use, from a script file: /usr/bin/log stream --predicate 'subsystem == "com.oomol.CloseUp"' --info --style compact.
  • Enumerate window frames with CGWindowListCopyWindowInfo(.optionOnScreenOnly), keep kCGWindowLayer == 0, drop the Dock. Map a frame to its AX window by CGWindowID via private _AXUIElementGetWindow.
  • Act via the window's AX buttons (kAXCloseButton/kAXMinimizeButton/ kAXZoomButton + kAXPressAction); quit via NSRunningApplication.terminate().
  • Show a traffic-light only for a control the window actually has — resolved via AX, so display now needs Accessibility. CGWindowList can't tell a real window from a popover/sheet/chrome-less panel, so filtering on it alone lit up auxiliary windows (the "lights on popups" bug). Resolve the hovered window's AX element (AXUIElementCreateApplication → match by _AXUIElementGetWindow) and keep only the actions whose AX button exists; a window with none → no overlay. WindowCapabilities / WindowCapabilityResolving (CloseUpKit, tested) is the pure intersection; the live AccessibilityCapabilityResolver returns nil when AX is untrusted so the overlay falls back to showing all enabled actions (legacy no-AX behaviour) instead of vanishing. Accessibility is required precisely because the filter is AX-tree-driven: only the AX tree distinguishes a real, closable window from a chrome-less auxiliary surface. (The rest of the engine — a CGEvent tap for input + an NSTimer-style poll for lifecycle, CGWindowList for geometry, no AXObserver — needs no AX; the role/button filtering is what does.)
  • Show NO overlay on a native-full-screen window — gate it on the AXFullScreen attribute, NOT on button presence. A full-screen app lives in its own Space; in Mission Control its thumbnail sits in the top Spaces strip (or App Exposé enumerates it at near-full display size), so the straddling cluster anchors at minY - buttonSize/2 - clusterPadding — off the top of the screen, the user's "lights at the wrong position" report. Traffic-light controls make no sense on a Space tile anyway, so CloseUp shows none on a full-screen app — the behaviour the user asked for. The trap: a full-screen window's AX tree STILL exposes kAXCloseButton/kAXMinimizeButton/kAXZoomButton, so the button-presence filter alone lights it up; you MUST check full-screen explicitly. AXWindow.isFullScreen reads the undocumented-but-stable "AXFullScreen" window attribute (no public kAX… constant exists; the de-facto signal yabai/Amethyst use); AccessibilityCapabilityResolver returns WindowCapabilities.none when it is true, so repositionOverlay orders the overlay out. Verified the signal directly: a real native-full-screen window (entered via ⌃⌘F) reads AXFullScreen == true through the resolver's exact match-by-windowID path, while every normal window reads false (so they are never suppressed). NB on a multi-display setup a native-full-screen app is NOT enumerated as a kCGWindowLayer == 0 window during a Mission Control session at all (it only shows in the top strip, which is not a CGWindowList window) — App Exposé is where it appears near-full with AXFullScreen == true; CloseUp sessions only on Mission Control, so reproducing the on-screen bug is config-dependent, but the AX guard is correct for every path that can enumerate the window.
  • The traffic-light cluster STRADDLES the thumbnail's top edge (half above the window), so hover resolution must be STICKY to the cluster — never re-resolve the hovered window by frontmost(containing:) alone while the cursor is over the current cluster. Since clusterFrameCG.minY == windowFrame.minY - buttonSize/2 - clusterPadding (≈16px above the top edge), the outer half of every button sits outside the window rect. frontmost(containing:) only tests the window rect, so the instant the cursor reaches the off-thumbnail half it returns nil/another window → hovered flips → repositionOverlay tears the lights down: the "lights vanish on the outside half" field bug, probabilistic because whether another window underlies that 16px band varies. Fix in trackMouse: let overCurrentCluster = geometry?.clusterFrameCG.contains(location) ?? false and keep hovered when overCurrentCluster && hovered != nil, else frontmost. geometry is non-nil only while the hovered window's lights show and always describes hovered, so the sticky region is exactly that window's own buttons. (This mirrors how a native title bar feels — its controls hang off the thumbnail and stay lit on the outer half.) Verified on-screen: cursor parked on a button's outer half (CG y above the window top) keeps the lights; moving above the whole cluster logs hover W→0 and correctly releases.
  • Size the overlay NSWindow to the cluster BEFORE mounting the NSHostingView content — never mount-then-resize, or the lights "fly in from the top-left". The overlay window is created at contentRect: .zero. If repositionOverlay sets the SwiftUI content first and grows the window after, the hosting view lays the HStack out inside a 0×0 bounds — all buttons collapsed at the top-left origin — and the subsequent setFrame is seen by SwiftUI as an animatable layout change from that collapsed state, so the buttons animate out to the row from the corner (probabilistic: only when the collapsed first layout gets composited before the resize lands). Correct order: window.setFrame(geo.nsWindowFrame, display: false) (defer redraw so no stale frame paints at the new size) → setOverlayContent(...) (a FRESH hosting view is born already at the final bounds, so its first layout is the final one — no 0→size transition) → window.orderFront(nil). Holds for every show path because they all route through repositionOverlay (hover-change reuse, make-before-break reanchor, re-tile recreate). NB the per-button hover lift (scaleEffect + DS.Motion.overlay) is NOT the cause — scaleEffect is a render transform and never reflows siblings; the fly-in is purely the hosting view's 0→size first layout. Verified frame-by-frame at 120fps: pre-fix ~8 frames of corner-collapse→spread on appear; post-fix the cluster appears atomically at its final position in one frame.
  • Overlay = borderless NSWindow at .screenSaver level (1000); a global CGEvent tap swallows only clicks/keys hitting a button and passes everything else through, so Mission Control keeps its own Esc/arrow handling. Re-enable the tap on .tapDisabledByTimeout/.tapDisabledByUserInput.
  • Overlay collectionBehavior must NOT include .transient. The system hides transient windows during Mission Control gestures, so a 3-finger swipe — even one that changes no Space (e.g. swiping past the last desktop) — hides the overlay for the rest of the MC session; lights vanish on every window until MC is reopened. Use [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] (an overlay that sets none of these is immune). The trigger is the gesture, not the Space change — reproduce with the real trackpad swipe; ⌃→/⌃← keyboard Space-switches do NOT trigger it.
  • Rebuild the overlay NSWindow on every MC re-tile — detect it by thumbnail frame churn, NOT by activeSpaceDidChange. A window ordered-in before MC re-tiles stays composited BELOW the Dock's rebuilt exposé surface — even at .screenSaver level and still reporting isVisible == true — so the lights vanish; only a window ordered-in after the re-tile lands above it. activeSpaceDidChange is NOT a sufficient trigger: the worst case is a swipe past the last desktop that changes no Space — it fires no activeSpaceDidChange and does not even bump the exposé-surface window number, yet it re-tiles and sinks the overlay (feature dies until you switch Spaces). The one signal that catches every re-tile (Space switch, boundary swipe, full-screen transition) is the thumbnail frames shifting: refreshWindows diffs per-window-id frames each tick and, once the churn settles (held still ~2 ticks), recreateOverlayWindow() (orderOut+close()+nil+ensureOverlayWindow) once and re-anchors — which also fixes the lights lagging the moved thumbnail (the "misaligned lights"). Rebuild on settle, not during churn, or it flickers through the animation. Corollaries that cost hours: (1) visible=y in the log is a LIAR — the sunk window believes it is visible; confirm on a real screen, never the log. (2) Keyboard ⌃→/⌃← does NOT reproduce the sink (same-window orderFront recovers for keyboard but not the trackpad re-tile), so it is only verifiable with the real trackpad gesture, never synthetic input.
  • Gate the overlay's display on the layout being SETTLED — never show lights while Mission Control is still entering (or re-tiling). The same thumbnail-frame churn signal that drives the re-tile rebuild (above) also gates the first appearance: trackMouse early-returns while layoutSettled == false, so the lights stay hidden until the frames hold still for settleTicks (~200 ms), at which point the overlay window is rebuilt + re-anchored and shown. Without this the lights appeared the instant MC began opening and chased the thumbnails across the screen — as MC tiles, different windows sweep under the fixed cursor, so frontmost(containing:) keeps changing and the overlay jumps from frame to frame. Barely visible on a fast swipe (windows are already at rest by the first refresh → no churn → shows once at the final spot), glaring on a slow one. The lights must appear only once MC has finished entering, for exactly this reason. layoutSettled is false at every beginSession/resyncSession and whenever churn resumes (which also orderOuts the overlay); the settle detector flips it true. The fast path must NOT special-case "no churn → show now": a fast enter still resolves through the 2-stable-tick wait (~200 ms, imperceptible) so there is one uniform rule. Verified with dockswipe mission-control --steps 60 --interval 16000 (~1 s slow enter) + live log stream: pre-fix 3 overlay show at moving positions during the enter; post-fix 0 during the enter, one at the settled position.
  • Gate display on cursor MOVEMENT too — the lights appear only when the pointer MOVES, never on a stationary cursor. This is the load-bearing one; frame-settle alone is NOT enough. The mechanism: drive the overlay off a poll whose show path early-outs when the cursor has not moved since the last tick (a plain CGPoint inequality on lastMouseLocation) — only (re)resolve/show the cluster when the cursor actually moved. The CGEvent tap is kept minimal — {LeftMouseUp, KeyDown, OtherMouseUp}, i.e. clicks + hotkeys ONLY, no mouseMoved/scroll/gesture API — so the engine has NO way to read the trackpad gesture phase, by design; the cursor-movement check is what keeps the lights from appearing early. The consequence: a Mission Control swipe never moves the cursor, so while MC is entering — or when the user pauses a 3/4-finger swipe halfway (fingers down, thumbnails momentarily still) — the cursor is stationary and the lights stay hidden; they appear only once the user moves the pointer, by which point MC has finished entering. Frame-stability (layoutSettled) alone CANNOT tell a paused gesture from a settled layout — it fired on the pause (the user's bug report); cursor movement is the missing signal. CloseUp implements this in trackMouse via lastMouseLocation, and the two gates compose: layoutSettled suppresses a cursor that moves during the enter animation (no chasing); the cursor-movement gate suppresses a stationary cursor through the enter/pause. Verified on-screen: a slow dockswipe enter with the cursor PARKED over a window shows 0 lights even after the layout settles; a tiny cliclick move then lights exactly that window. (The primary gate is frame-stability — an EXACT, no-tolerance whole-set identity of {CGRect, windowNumber, ownerName} across two consecutive poll ticks, keying MC-open on the Dock-owned exposé surface at kCGWindowLayer == 18; the cursor-move check is the secondary re-show guard.)
  • On a fast single-window enter the settle-time show can fail to PAINT — recover it with a post-settle re-anchor watch, NOT by detecting a sink. Field bug #3: current Desktop has one near-full-screen window, cursor inside it, a FAST 3-finger swipe up — probabilistically no traffic lights, and (unlike a normal miss) moving the cursor within the same window does not bring them back; only moving out and back does. Per-tick diagnostic logging during a real-trackpad repro proved the cause: repositionOverlay runs once at settle but the overlay never lands on screen — it is left isVisible=false, or ordered front before the Dock finished compositing its layer-18 surface so it is stacked visually underneath. Because the show is gated on the hovered windowID changing, a stationary cursor (an MC swipe never moves it) and even in-window movement never re-resolve, so it stays missing until the hovered window changes (the user's "move out and back"). A z-order / isVisible sink check CANNOT catch this — a window you just orderFront'd reads as front-most AND isVisible=true in CGWindowList while still visually underneath (the diagnostic's sunk= never once fired in a failing repro). Fix: a post-settle watch (sinkWatchTicks, ~30 ticks ≈1.8 s, reset at every session boundary, set at settle) during which trackMouse at a few FORCED ticks ({6,14,22,28}) UNCONDITIONALLY re-anchors a FRESH overlay window via make-before-break (overlayWindow=nil; ensureOverlayWindow(); repositionOverlay(); old.orderOut; old.close() — show the new one before closing the old → blink-free, user-confirmed at 4 re-anchors/enter with no flicker). A fresh window ordered-in after the Dock has finished compositing lands above the surface; the spread-out forced ticks cover the range of finish times, so a stationary cursor self-heals with no movement. An A/B across builds proved the unconditional forced re-anchor is what fixes it (a detection-gated build still failed, since the sink check never fired). A click must END the watch: hideOverlay() sets sinkWatchTicks=0, else a forced tick re-shows the lights on a window the user just hit a traffic-light button on (button hits keep hovered and deliberately don't suppress) — the "button hit re-shows only on the next fresh hover" rule. As with the sink/re-tile bugs, synthetic input (dockswipe) does NOT reproduce this no-paint race — a 4-round dockswipe run showed every enter succeeding; only the real trackpad does.
  • Once a session is open, IGNORE the live AX expose notification — do not resync on it. The Dock's AXExposeShowAllWindows is laggy (observed arriving ~1 s after the layer-18 poll already began the session); feeding it to resyncSession while sessionActive forced a redundant same-position hide/re-show blink right as the lights settled. handleStateChange now guard !sessionActive else { return } then beginSession — the poll + churn detector are authoritative for a running session, and a genuine Space change still re-syncs via handleActiveSpaceChange. The AX notification is now purely a fast-path for the initial open.
  • Drop windows reported off-screen during the MC re-tile. While Mission Control re-tiles after a Space switch, CGWindowList transiently reports a window at a garbage off-screen position; if it covers the cursor the overlay anchors off-screen. WindowInfo.anchoredOnScreen(displays:inset:) (vs CGDisplayBounds) filters these in refreshWindows; refresh at 100 ms (not 250) to track the animation.
  • Coordinate flip: CGWindowList/CGEvent are top-left origin; NSWindow is bottom-left — convertedY = screenHeight - cgY - height. The #1 bug here. Pick the screen by frame containment, never NSScreen.screens.first.
  • In-Mission-Control action shortcuts (⌘W/⌘M/⌘H/⌘Q, ⌥-batch) are intercepted by the tap only while MC is open — they are NOT global hotkeys (⌘W globally would hijack every close). Customizable via KeyboardShortcuts.Recorder + KeyboardShortcuts.disable() (suppress global firing) + getShortcut(for:) matched in the tap. True global hotkeys are reserved for safe actions.
  • Any left click while MC is open dismisses the overlay instantly — then suppress re-show through the exit with a SELF-CLEARING flag, never a session-long latch. handleClick orderOuts the overlay on every click (a button hit performs its action first, then hides); relying on the close-poll's endSession (~600 ms) instead leaves the lights lingering after the click. A pass-through click (thumbnail / empty space → MC is exiting) additionally sets suppressOverlayReshow, because MC's exit animation churns thumbnail frames and trackMouse would otherwise re-show the overlay at a transient garbage top-left frame for a tick or two before endSession — the "post-click flash" on the desktop. Clear the flag on session begin/end plus a ~2 s safety timer (sized to outlast the exit + the ~600 ms close-poll, so it never fires mid-exit and re-flashes) — the timer is the fallback for a click that leaves MC open (empty space), so the lights recover on the next hover. Do NOT hold it until the next beginSession (a session-long latch): that reintroduces the documented "lights dead for the rest of the MC session" class for any click that doesn't dismiss MC. A button hit must NOT suppress — MC stays open, so the lights re-show on the next hover for back-to-back window management.

Internationalization (in-app override, 9 languages)

en (source), zh-Hans, zh-Hant, ja, fr, de, es, pt (Brazilian house style), ru. Switching is live (no restart): the chosen \.locale is injected at every scene root with .id(localeIdentifier) to rebuild the subtree (localized(with:)).

  • Never display text localized by someone else's bundle. Framework/3rd-party localizedDescription resolves against the system language → mixed UI. Map errors to your own catalog keys; log the original. A deliberate non-UI use opts out with an i18n-exempt comment.
  • Native-bridging surfaces (status-bar menu, window titles, alerts, .help, .navigationTitle) bypass the injected locale — resolve them through appState.loc(...) / AppKitStrings, never a bare LocalizedStringKey.
  • Re-inject .environment(\.locale, appState.locale) at every .sheet (and popover/separate scene) — they reset to the system locale.
  • KeyboardShortcuts resource bundle is redirected to the app language via ThirdPartyBundleLocalization (re-classing the SPM bundle); a guard test pins its name + per-language coverage.
  • Coverage is per-change: every user-facing string gets a finished translation for all 8 non-English languages in the same commit. Scripted catalog edits round-trip-assert and produce addition-only diffs (never reorder keys). Cross-boundary machine-facing text (error codes) stays English.
  • Guard tests (Tests/CloseUpKitTests/LocalizationGuardTests.swift) enforce the above; keep them green and extend them as new surfaces/packages appear.

Quality bars

  • Zero build warnings; Swift strict concurrency complete (new warnings block).
  • Native-and-consistent beats clever-and-custom; semantic system colors only; design tokens in DS (UI/DesignSystem.swift) — never inline literals.
  • TCC permission state is observed by polling AXIsProcessTrusted() (macOS posts no grant notification); re-attach observers on grant, recreate on revoke.
  • Visual work is unverified until seen on a real screen — flag "needs on-screen confirmation".

Dependencies (pinned in project.yml, never add a framework without asking)

Sparkle 2.9.0 (update) · KeyboardShortcuts 1.10.0 (hotkeys) · PermissionFlow 2.5.0 (AX permission).