From 9d1cdbf9c230875a316ef9860e8e4611c4b8fa0a Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Fri, 21 Aug 2026 11:36:29 +0330 Subject: [PATCH 01/36] fix(gui): open minimized honours the setting The "Open minimized" preference inferred a login launch from NSApplication.launchIsDefaultUserInfoKey, which was its only input and read wrong in both directions: the window appeared at login with the setting on, and stayed hidden on a manual launch. MainWindow also left NSWindow.isRestorable at its default true, so AppKit state restoration could reopen the window without consulting the setting at all. Register a LaunchAgent shipped in the bundle instead of SMAppService.mainApp. Its ProgramArguments end in --background, so CommandLine.arguments is ground truth rather than a guess, and LaunchVisibility takes backgroundLaunch: instead of deliberateLaunch:. Existing installs migrate once on launch, gated on the old registration having been enabled so an upgrade never switches login-at-launch on for someone who had it off. build-app.sh fails the build if the plist is missing: a bundle without it registers nothing and silently stops starting at login. Refs docs/adr/0014-login-item-launch-marker.md Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +++ docs/adr/0014-login-item-launch-marker.md | 109 ++++++++++++++++++ docs/adr/README.md | 1 + docs/contribute/testing.md | 23 +++- docs/usage/cli.md | 5 +- gui/macos/LoginAgent.plist | 33 ++++++ .../DezhbanCore/LaunchVisibility.swift | 31 +++-- .../Sources/DezhbanMenu/AppDelegate.swift | 26 +++-- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 54 +++++++-- .../Sources/DezhbanMenu/MainWindow.swift | 6 + .../LaunchVisibilityTests.swift | 32 ++++- gui/macos/build-app.sh | 14 +++ 12 files changed, 313 insertions(+), 34 deletions(-) create mode 100644 docs/adr/0014-login-item-launch-marker.md create mode 100644 gui/macos/LoginAgent.plist diff --git a/CHANGELOG.md b/CHANGELOG.md index 91617b9..6918e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ current as you land changes. ## [Unreleased] +### Fixed + +- **"Open minimized" now actually decides whether the window opens.** The app + used to infer a login launch from `NSApplication.launchIsDefaultUserInfoKey`, + which reported wrong in both directions — the window appeared at login with + the setting on, and stayed hidden on a manual launch. The login item is now a + LaunchAgent shipped inside the bundle that passes `--background`, so the app + reads the launch kind instead of guessing it + ([ADR-0014](docs/adr/0014-login-item-launch-marker.md)). The main window also + opted out of AppKit state restoration, which could reopen it at launch without + consulting the setting at all. Existing installs are migrated on first launch; + if you had login-at-launch switched off, it stays off. + ## [0.11.0] - 2026-08-21 ### Added diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md new file mode 100644 index 0000000..db35402 --- /dev/null +++ b/docs/adr/0014-login-item-launch-marker.md @@ -0,0 +1,109 @@ +# ADR-0014: The login item carries an explicit launch marker + +**Date**: 2026-08-21 +**Status**: accepted +**Deciders**: Behnam RK + +## Context + +The macOS app's "Open minimized" setting (`LaunchVisibility`: never / always / +only at login) needs to know one thing: did macOS start this app at login, or +did the user start it? The `bootOnly` case — the default, and the behaviour the +app had before the setting existed — is defined entirely by that distinction. + +Until now the app asked AppKit, reading +`NSApplication.launchIsDefaultUserInfoKey` from the +`applicationDidFinishLaunching` notification. That key is documented as false +for launches the system performed on the user's behalf, and it was the sole +input to the decision. In practice it read wrong in both directions: the window +appeared at login with the setting on, and failed to appear on a manual Finder +launch. Compounding it, `MainWindow`'s `NSWindow` left `isRestorable` at its +default `true`, so AppKit's state restoration could reopen the window at launch +without consulting the setting at all. + +The app registers itself for login with `SMAppService.mainApp`, which relaunches +the bundle with no arguments and no marker of any kind — so there was nothing +else to consult. Any fix that stayed with `mainApp` would have had to replace +one heuristic with another. + +## Decision + +Register a **LaunchAgent** shipped inside the bundle +(`Contents/Library/LaunchAgents/com.behnam-rk.dezhban.app.login.plist`, via +`SMAppService.agent(plistName:)`) whose `ProgramArguments` end in +`--background`. `LaunchVisibility.isBackgroundLaunch(arguments:)` reads that +marker from `CommandLine.arguments`, and `opensWindow(backgroundLaunch:)` +replaces `opensWindow(deliberateLaunch:)`. `MainWindow` additionally sets +`isRestorable = false` so state restoration cannot reopen the window behind the +setting's back. + +An absent marker reads as a user launch. Existing installs are migrated once, on +launch, by `LoginItem.migrateFromMainAppRegistration()`. + +## Alternatives considered + +### Alternative 1: keep `launchIsDefaultUserInfoKey`, add `NSApp.isActive` as a tiebreak + +A login-item launch does not activate the app; a Finder or Dock launch does. + +- **Pros**: no packaging change at all; ships in one file. +- **Cons**: still a heuristic, and now a compound one. Activation state at + `applicationDidFinishLaunching` races anything else competing for focus at + login, which is precisely the moment the machine is busiest. +- **Why not**: it trades a signal that is wrong sometimes for a signal that is + wrong less often. The setting has a right answer and the app should know it, + not estimate it. + +### Alternative 2: drop the three-way setting for a plain on/off + +"Open the window when Dezhban starts", yes or no. Nothing to detect. + +- **Pros**: the bug becomes unreachable; the least code of any option. +- **Cons**: loses the default behaviour — quiet at boot, visible when you launch + it yourself — which is what almost everyone wants and what the app did before + the setting existed. Every user would have to pick one of two worse options. +- **Why not**: deleting a feature is not a fix for being unable to implement it. + +### Alternative 3: a separate login-item helper application + +A small helper app in `Contents/Library/LoginItems` that launches the main app +with an argument, the pre-`SMAppService` pattern. + +- **Pros**: also deterministic; long-established. +- **Cons**: a second executable to build, sign, version and keep in step. The + agent plist achieves the identical result with a file that has no code in it. +- **Why not**: strictly more machinery for the same guarantee. + +## Consequences + +### Positive + +- The launch kind is a fact, not an inference. `LaunchVisibility` is testable + against a literal argument list rather than an AppKit notification. +- State restoration can no longer reopen the window independently of the + setting. +- Both directions of the original defect are fixed by the same change. + +### Negative + +- The bundle now has a mandatory `Contents/Library/LaunchAgents` payload. + `build-app.sh` fails the build if it is missing, because a bundle without it + registers nothing and the app silently stops starting at login. +- The plist's `Label`, its filename, and `LoginItem.plistName` must agree. + launchd rejects a mismatch and `SMAppService` reports it only as a status, so + the failure is quiet by nature; the plist comments say so at both ends. + +### Risks + +- **A user who had login-at-launch enabled loses it on upgrade.** + `migrateFromMainAppRegistration()` unregisters `mainApp` and registers the + agent, gated on the old registration having been enabled — so an upgrade never + switches the login item *on* for someone who had it off. If the unregister + fails, the register still runs: a duplicate entry in System Settings is + cosmetic, whereas skipping it would leave a login launch that never sets + `--background`, which is the bug being fixed. +- **The marker could be passed by something other than the agent**, making a + user launch look like a login launch. The only consequence is a window that + does not open, and the Dock icon and "Open Dezhban…" both open it + unconditionally in every mode — the setting can never make the window + unreachable. diff --git a/docs/adr/README.md b/docs/adr/README.md index 61dbc48..2d76a1b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,6 +25,7 @@ New records use [template.md](template.md) and take the next free number. | [0011](0011-biometric-enrollment-requires-a-signed-build.md) | Biometric token enrollment requires a signed build, so unsigned builds must refuse it | accepted, implemented (Alternative 3 superseded by 0012) | | [0012](0012-app-checked-biometrics-on-unsigned-builds.md) | App-checked biometrics on unsigned builds, rather than no biometrics | accepted, implemented | | [0013](0013-geo-provider-pass-opt-out.md) | The geo-provider pass gets an opt-out, not a redesign | accepted, implemented | +| [0014](0014-login-item-launch-marker.md) | The login item carries an explicit launch marker | accepted, implemented | > **0006 is the one to read first if you are touching the geo lookup.** It records why > the obvious implementation silently defeats the exit-country check, and it exists diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index ea73ab9..25ccbb6 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -757,8 +757,27 @@ task gui:build && open dist/Dezhban.app still works with the daemon stopped and with the main window unable to open. - [ ] **Window opening.** "Open Dezhban…" and a Dock-icon click both open/focus - the main window; a fresh app launch opens **no** window (menubar + Dock - only); closing the window (⌘W) leaves the app and icon running. + the main window; closing the window (⌘W) leaves the app and icon running. + Both work in **every** "Open minimized" mode — the preference governs the + launch only and must never make the window unreachable. +- [ ] **"Open minimized" honours the setting** + ([ADR-0014](../adr/0014-login-item-launch-marker.md)). With "Open this app + at login" on, for each mode: **Only at login** (the default) → log out and + back in, **no** window; then launch from Finder, window opens. + **Always** → no window either way. **Never** → window both ways. The + marker is what makes this work, so also confirm the login launch carries + it: `ps -o args= -p "$(pgrep -x DezhbanMenu)"` ends in `--background` + after a login launch and does not after a Finder launch. +- [ ] **Login-item migration is one-way and never opts you in.** On an install + that predates the agent: with login-at-launch **on**, launch once, then + confirm `SMAppService.mainApp` is no longer registered while the agent is + (`launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` succeeds) and + the Settings toggle still reads on. Repeat with login-at-launch **off**: + it must still be off, and the agent must not be registered. +- [ ] **State restoration cannot reopen the window.** With the window open and + "Close windows when quitting an application" *unchecked* in System + Settings → Desktop & Dock, quit and relaunch in a mode that should open no + window — it must stay closed. - [ ] **Posture tracking.** Drive the daemon with `--simulate-country IR` / `US` and confirm the menu bar icon *and* the Dock tile flip red/teal and the window's Overview updates within ~1 s. diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 4c77385..cd15350 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -646,7 +646,10 @@ The main window's sidebar sections: (Install service… / Guard up). - **Settings** — startup ("Start the guard at boot" installs the launchd system service so enforcement survives reboots; "Open this app at login" via - `SMAppService`; **"Open minimized"** — Never / Always / Only at login, an + `SMAppService`, registering a LaunchAgent inside the bundle that starts the + app with `--background` so it can tell a login launch from one you asked for + ([ADR-0014](../adr/0014-login-item-launch-marker.md)); **"Open minimized"** — + Never / Always / Only at login, an app-local preference that decides whether the main window opens when Dezhban starts, defaulting to "Only at login", which is what the app always did; the Dock icon and the menubar's "Open Dezhban…" open it regardless; diff --git a/gui/macos/LoginAgent.plist b/gui/macos/LoginAgent.plist new file mode 100644 index 0000000..431f40b --- /dev/null +++ b/gui/macos/LoginAgent.plist @@ -0,0 +1,33 @@ + + + + + + Label + com.behnam-rk.dezhban.app.login + + BundleProgram + Contents/MacOS/DezhbanMenu + + ProgramArguments + + Contents/MacOS/DezhbanMenu + --background + + RunAtLoad + + + KeepAlive + + LimitLoadToSessionType + Aqua + + diff --git a/gui/macos/Sources/DezhbanCore/LaunchVisibility.swift b/gui/macos/Sources/DezhbanCore/LaunchVisibility.swift index d41051d..aaba72e 100644 --- a/gui/macos/Sources/DezhbanCore/LaunchVisibility.swift +++ b/gui/macos/Sources/DezhbanCore/LaunchVisibility.swift @@ -42,21 +42,38 @@ public enum LaunchVisibility: String, CaseIterable, Identifiable, Sendable { /// Whether to open the main window for this launch. /// - /// `deliberateLaunch` is AppKit's `launchIsDefaultUserInfoKey`: false when - /// the launch was performed on the user's behalf (a login item, state - /// restoration, opening a file) rather than at their request. A missing - /// flag reads as an ordinary launch, so the window still opens if AppKit - /// ever stops reporting it — the caller supplies that default. + /// `backgroundLaunch` is true when macOS started the app at login rather + /// than the user starting it. It is read from `--background` in + /// `CommandLine.arguments`, which only the login LaunchAgent passes (see + /// `LoginItem` and docs/adr/0014-login-item-launch-marker.md) — an explicit + /// marker, not a heuristic. The predecessor asked AppKit's + /// `launchIsDefaultUserInfoKey` and got the answer wrong in both + /// directions: the window appeared at login and failed to appear on a + /// manual launch. + /// + /// The absent argument therefore reads as a user launch, which is the safe + /// default: the worst case is a window the user did not ask for, never a + /// window they cannot reach. /// /// Note this governs the LAUNCH only. The Dock icon and the menubar's /// "Open Dezhban…" open the window regardless, in every mode — a /// preference about startup noise must never become a way to lose access /// to the window. - public func opensWindow(deliberateLaunch: Bool) -> Bool { + public func opensWindow(backgroundLaunch: Bool) -> Bool { switch self { case .never: return true case .always: return false - case .bootOnly: return deliberateLaunch + case .bootOnly: return !backgroundLaunch } } + + /// The launch marker the login LaunchAgent passes. Public so the app and + /// its tests name the same string, and so a rename cannot drift away from + /// `LoginAgent.plist`. + public static let backgroundArgument = "--background" + + /// Reads the marker out of a process argument list. + public static func isBackgroundLaunch(arguments: [String]) -> Bool { + arguments.contains(backgroundArgument) + } } diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 2244003..0550644 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -32,7 +32,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// a thing to hammer GitHub with. See UpdateChecker's doc comment. private static let updateCheckInterval: TimeInterval = 24 * 60 * 60 - func applicationDidFinishLaunching(_ notification: Notification) { + func applicationDidFinishLaunching(_: Notification) { NotificationManager.requestAuthorizationIfNeeded() // Resolve the config path once, off the main thread, before any pane asks for // it — every later read is then a memoized lookup rather than a shell-out on @@ -55,21 +55,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { statusItem.menu = menu watchdog.start() refresh() + // Move any pre-agent install onto the login LaunchAgent before anything + // reads the launch marker, so the NEXT login is already correct. + LoginItem.migrateFromMainAppRegistration() // Launching the app shows the app: reaching the main window only through // the menubar dropdown made opening it a two-step discovery problem, and // the menubar item stays available either way. // - // Except when the launch wasn't the user's doing. AppKit clears this flag - // for launches it performed on their behalf rather than at their request — - // a login item, a state restoration, opening a file — and a window - // appearing unbidden at every boot is exactly the noise a menubar app - // should not make. A missing flag reads as an ordinary launch, so the - // window still opens if AppKit ever stops reporting this. - let deliberateLaunch = (notification.userInfo?[NSApplication.launchIsDefaultUserInfoKey] as? Bool) ?? true + // Except when the launch wasn't the user's doing. The login LaunchAgent + // passes --background and nothing else does, so this is an explicit + // marker rather than an inference. It replaces + // `NSApplication.launchIsDefaultUserInfoKey`, which was the sole input + // here and read wrong in both directions — the window appeared at login + // and failed to appear on a manual launch. See + // docs/adr/0014-login-item-launch-marker.md. + let backgroundLaunch = LaunchVisibility.isBackgroundLaunch(arguments: CommandLine.arguments) // The Settings "Open minimized" choice decides what to do with that. - // Its default, .bootOnly, is exactly the behaviour described above, so - // anyone who never touches the setting sees no change. - if LaunchPreference.current.opensWindow(deliberateLaunch: deliberateLaunch) { + // Its default, .bootOnly, is the long-standing behaviour — quiet at + // login, visible when you start it yourself. + if LaunchPreference.current.opensWindow(backgroundLaunch: backgroundLaunch) { MainWindow.shared.open() } AppState.shared.refreshServiceState() diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index c5dffc7..3398d33 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -2,13 +2,25 @@ import Foundation import ServiceManagement /// Wraps the menubar app's own login-item registration via SMAppService -/// (macOS 13+). No LaunchAgent plist to ship — the system framework registers -/// the currently-running .app to relaunch at login. Requires a proper bundle -/// with a bundle identifier (assembled by build-app.sh), so it is a no-op / -/// failure when run as a bare SwiftPM binary. +/// (macOS 13+). Requires a proper bundle with a bundle identifier (assembled by +/// build-app.sh), so it is a no-op / failure when run as a bare SwiftPM binary. +/// +/// It registers an **agent** — `Contents/Library/LaunchAgents/…login.plist`, +/// installed by build-app.sh — rather than `SMAppService.mainApp`. The two are +/// equivalent as far as "start me at login" goes; the difference is that the +/// agent's `ProgramArguments` carry `--background`, which is the only reliable +/// way for the app to know that macOS started it rather than the user. See +/// `LaunchVisibility` and docs/adr/0014-login-item-launch-marker.md. enum LoginItem { + /// Must match `LoginAgent.plist`'s `Label` and the filename build-app.sh + /// installs it under; launchd rejects a mismatch, and SMAppService reports + /// it only as a `.notFound` status. + private static let plistName = "com.behnam-rk.dezhban.app.login.plist" + + private static var service: SMAppService { .agent(plistName: plistName) } + static var isEnabled: Bool { - SMAppService.mainApp.status == .enabled + service.status == .enabled } /// Toggles login-at-launch. Returns the resulting enabled state; on error it @@ -17,13 +29,41 @@ enum LoginItem { static func toggle() -> Bool { do { if isEnabled { - try SMAppService.mainApp.unregister() + try service.unregister() } else { - try SMAppService.mainApp.register() + try service.register() } } catch { NSLog("DezhbanMenu: login item toggle failed: \(error)") } return isEnabled } + + /// Moves an install that registered `SMAppService.mainApp` (every build + /// before the agent existed) onto the agent, once. + /// + /// Deliberately gated on the OLD registration being enabled: a user who had + /// login-at-launch switched off must not have it switched on by an upgrade. + /// Idempotent — after the first run `mainApp.status` is no longer `.enabled`, + /// so this does nothing on every launch thereafter, and it never touches an + /// agent registration that already exists. + static func migrateFromMainAppRegistration() { + guard SMAppService.mainApp.status == .enabled else { return } + do { + try SMAppService.mainApp.unregister() + } catch { + NSLog("DezhbanMenu: could not unregister the legacy login item: \(error)") + // Fall through and register the agent anyway: two registrations is a + // cosmetic duplicate in System Settings, but skipping the register + // would leave the user with a login launch that never sets + // --background — the exact bug this migration exists to fix. + } + if !isEnabled { + do { + try service.register() + } catch { + NSLog("DezhbanMenu: could not register the login agent: \(error)") + } + } + } } diff --git a/gui/macos/Sources/DezhbanMenu/MainWindow.swift b/gui/macos/Sources/DezhbanMenu/MainWindow.swift index 4bc23ab..d4b49a5 100644 --- a/gui/macos/Sources/DezhbanMenu/MainWindow.swift +++ b/gui/macos/Sources/DezhbanMenu/MainWindow.swift @@ -68,6 +68,12 @@ final class MainWindow: NSObject, NSWindowDelegate { styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) win.isReleasedWhenClosed = false + // AppKit state restoration would otherwise reopen this window at launch + // on its own, entirely outside the "Open minimized" check in + // AppDelegate — which is half of why that setting appeared not to work. + // Frame and sidebar position still persist; those go through + // setFrameAutosaveName and the split view's autosave, not restoration. + win.isRestorable = false win.delegate = self // 820 wide, not 640: the Help pane's inner HSplitView needs 620pt of // detail (200 sidebar + 420 page) and could not fit at the old minimum diff --git a/gui/macos/Tests/DezhbanCoreTests/LaunchVisibilityTests.swift b/gui/macos/Tests/DezhbanCoreTests/LaunchVisibilityTests.swift index 3cbfc88..d842186 100644 --- a/gui/macos/Tests/DezhbanCoreTests/LaunchVisibilityTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/LaunchVisibilityTests.swift @@ -3,20 +3,40 @@ import Testing struct LaunchVisibilityTests { /// The default must reproduce what the app did before the setting existed: - /// open on a deliberate launch, stay hidden when macOS started us at login. + /// open when the user starts it, stay hidden when macOS started us at login. /// An upgrade is a no-op for anyone who never touches it. @Test func bootOnlyIsTheOldUnconditionalBehaviour() { - #expect(LaunchVisibility.bootOnly.opensWindow(deliberateLaunch: true)) - #expect(!LaunchVisibility.bootOnly.opensWindow(deliberateLaunch: false)) + #expect(LaunchVisibility.bootOnly.opensWindow(backgroundLaunch: false)) + #expect(!LaunchVisibility.bootOnly.opensWindow(backgroundLaunch: true)) } @Test func neverAndAlwaysIgnoreHowTheLaunchHappened() { - for deliberate in [true, false] { - #expect(LaunchVisibility.never.opensWindow(deliberateLaunch: deliberate)) - #expect(!LaunchVisibility.always.opensWindow(deliberateLaunch: deliberate)) + for background in [true, false] { + #expect(LaunchVisibility.never.opensWindow(backgroundLaunch: background)) + #expect(!LaunchVisibility.always.opensWindow(backgroundLaunch: background)) } } + /// The marker is the whole mechanism: the login LaunchAgent passes it and + /// nothing else does. A rename here without a matching edit to + /// LoginAgent.plist silently restores the bug this replaced. + @Test func backgroundLaunchIsReadFromTheArgumentTheAgentPasses() { + #expect(LaunchVisibility.backgroundArgument == "--background") + #expect(LaunchVisibility.isBackgroundLaunch( + arguments: ["/Applications/Dezhban.app/Contents/MacOS/DezhbanMenu", "--background"])) + #expect(!LaunchVisibility.isBackgroundLaunch( + arguments: ["/Applications/Dezhban.app/Contents/MacOS/DezhbanMenu"])) + } + + /// An unmarked launch must read as a user launch. The failure mode of + /// guessing wrong in this direction is a window nobody asked for; guessing + /// wrong the other way hides the window from someone who did. + @Test func anEmptyArgumentListIsAUserLaunch() { + #expect(!LaunchVisibility.isBackgroundLaunch(arguments: [])) + #expect(LaunchVisibility.bootOnly.opensWindow( + backgroundLaunch: LaunchVisibility.isBackgroundLaunch(arguments: []))) + } + /// The raw values are persisted in UserDefaults, so renaming one silently /// resets every existing user's choice to the default. @Test func rawValuesArePersistedIdentifiers() { diff --git a/gui/macos/build-app.sh b/gui/macos/build-app.sh index 457ab45..2c6e4af 100755 --- a/gui/macos/build-app.sh +++ b/gui/macos/build-app.sh @@ -72,6 +72,20 @@ install -m 0755 "$HERE/askpass.sh" "$APP/Contents/Resources/askpass.sh" # ad-hoc codesign below, so the seal covers it. install -m 0644 "$REPO_ROOT/LICENSE" "$APP/Contents/Resources/LICENSE" +# Login-item LaunchAgent. SMAppService.agent(plistName:) reads it from exactly +# this directory, and it is the only thing that tells a login launch apart from +# a user launch: it passes --background, which LaunchVisibility keys on. A +# bundle assembled without it registers nothing and the app silently stops +# starting at login, so its absence is a build failure rather than a note. +# See docs/adr/0014-login-item-launch-marker.md. +mkdir -p "$APP/Contents/Library/LaunchAgents" +install -m 0644 "$HERE/LoginAgent.plist" \ + "$APP/Contents/Library/LaunchAgents/com.behnam-rk.dezhban.app.login.plist" +if [[ ! -f "$APP/Contents/Library/LaunchAgents/com.behnam-rk.dezhban.app.login.plist" ]]; then + echo "build-app.sh: the login LaunchAgent did not land in the bundle — the app would not start at login" >&2 + exit 1 +fi + # Documentation, rendered from the repo's own markdown into the bundle. Shipping # it means the help pane works with every byte of egress cut — which is exactly # when someone needs it — and that the docs always match the version they From b6564b3af9b83e78740c74389757fbeff994fb21 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Fri, 21 Aug 2026 21:56:16 +0330 Subject: [PATCH 02/36] fix(gui): the login agent must not spawn a second app, or re-arm itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the launch-marker change found the whole registration lifecycle untested, and three real defects in it. Registering a RunAtLoad launchd agent execs the binary immediately, and launchd — unlike the LaunchServices login item this replaced — does not care that the app is already running. Both callers of register() run while the app is up, so turning on "Open this app at login", or upgrading with it already on, left two menubar items, two Dock tiles and two state-file timers; the duplicate carries --background, so under the default "Only at login" it opened no window and there was no way to tell the icons apart. RunAtLoad has to stay true or the login launch never happens, so main.swift now exits a duplicate before the delegate installs anything. The rule is a total order over (launch date, pid) in DezhbanCore rather than a "does anyone else exist" test: two copies that each saw the other and each stood down would leave the Mac with no app. The migration inferred "already migrated" from a live mainApp.status read, which is only truthful when the unregister succeeded — and it can fail for real. It then re-ran on every launch, re-registering the agent after the user had switched login-at-launch off in Settings, with no way to turn it off again. It is now gated on a persisted flag and runs at most once per account. When the legacy item survives the attempt the agent is deliberately left unregistered: two launches at login, one with the marker and one without, is worse than the behaviour it replaces. isEnabled reports either registration, so the toggle tells the truth about whether anything starts the app at login, and switching it off retracts both. Uninstall left the agent registered. A LaunchServices login item goes away with its bundle; a per-user launchd job does not, and an orphan fails to load at every subsequent login. uninstall.sh boots it out for the console user and prints the one command other accounts need. Also: the build guard only restated what `install` under `set -e` had just proven, while the two invariants the comments called un-driftable went unchecked. It now asserts them — Label equals the filename SMAppService is given, and ProgramArguments still carry --background — both verified to fail the build when broken. And the plist declares AssociatedBundleIdentifiers, so the System Settings entry a user reaches for to stop the app starting at login reads "Dezhban" rather than a raw job label. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 ++- docs/adr/0014-login-item-launch-marker.md | 61 ++++++++-- docs/contribute/testing.md | 25 ++++ gui/macos/LoginAgent.plist | 8 ++ .../Sources/DezhbanCore/SingleInstance.swift | 59 ++++++++++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 111 +++++++++++++----- gui/macos/Sources/DezhbanMenu/main.swift | 33 ++++++ .../SingleInstanceTests.swift | 68 +++++++++++ gui/macos/build-app.sh | 34 +++++- packaging/macos/uninstall.sh | 22 ++++ 10 files changed, 390 insertions(+), 45 deletions(-) create mode 100644 gui/macos/Sources/DezhbanCore/SingleInstance.swift create mode 100644 gui/macos/Tests/DezhbanCoreTests/SingleInstanceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6918e66..6b196d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,18 @@ current as you land changes. reads the launch kind instead of guessing it ([ADR-0014](docs/adr/0014-login-item-launch-marker.md)). The main window also opted out of AppKit state restoration, which could reopen it at launch without - consulting the setting at all. Existing installs are migrated on first launch; - if you had login-at-launch switched off, it stays off. + consulting the setting at all. Existing installs are migrated once, on first + launch; if you had login-at-launch switched off, it stays off. + + Two consequences of the mechanism, handled in the same change. A launchd agent + starts the moment it is registered and — unlike the LaunchServices login item + it replaces — does not check whether the app is already running, so turning + "Open this app at login" on used to be able to leave you with two menubar + icons; a duplicate copy now exits at startup. And a launchd registration does + not disappear with the app bundle the way a login item did, so `uninstall.sh` + retracts it rather than leaving an entry that fails to load at every + subsequent login. The Login Items entry also reads "Dezhban" now instead of a + raw job label. ## [0.11.0] - 2026-08-21 diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index db35402..f48ac60 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -86,22 +86,63 @@ with an argument, the pre-`SMAppService` pattern. ### Negative -- The bundle now has a mandatory `Contents/Library/LaunchAgents` payload. - `build-app.sh` fails the build if it is missing, because a bundle without it - registers nothing and the app silently stops starting at login. -- The plist's `Label`, its filename, and `LoginItem.plistName` must agree. - launchd rejects a mismatch and `SMAppService` reports it only as a status, so - the failure is quiet by nature; the plist comments say so at both ends. +- The bundle now has a mandatory `Contents/Library/LaunchAgents` payload, and + `build-app.sh` asserts two things about it that no test can reach: that the + plist's `Label` equals the filename it is installed under (launchd rejects a + mismatch, and `SMAppService` reports that only as a status nobody reads), and + that `ProgramArguments` still carry `--background`. Deleting the marker or + renaming the label would otherwise pass `go test`, `swift test` and the build + while silently restoring the original bug. +- **Registering the agent starts a second copy of the app.** This is the real + cost of leaving `SMAppService.mainApp`. `mainApp` is a LaunchServices login + item, and LaunchServices refuses to launch a bundle that is already running; + an agent with `RunAtLoad` is not that — launchd `exec`s + `Contents/MacOS/DezhbanMenu` directly and dedupes nothing. Both callers of + `register()` run while the app is up (the Settings toggle, and the migration + below), so each would leave two menubar items, two Dock tiles, two 1-second + state-file timers and two update checkers — the duplicate carrying + `--background`, so under the default `bootOnly` it opens no window and there + is no way to tell the two icons apart. `RunAtLoad` has to stay true or the + login launch never happens, so the duplicate is caught at startup instead: + `yieldToRunningInstance()` in `main.swift` exits before the delegate installs + anything. The comparison is a total order over (launch date, pid) rather than + a "does anyone else exist" test, because two copies that each saw the other + and each stood down would leave the Mac with no app at all — see + `SingleInstance`. +- **Uninstalling has to retract the registration.** A LaunchServices login item + disappears with its bundle; a per-user launchd agent does not. Left behind it + fails to load at every subsequent login and lingers in System Settings as an + orphan job. `packaging/macos/uninstall.sh` boots it out for the console user + and prints the one command other accounts need, since root cannot reach + another user's launchd session. ### Risks - **A user who had login-at-launch enabled loses it on upgrade.** `migrateFromMainAppRegistration()` unregisters `mainApp` and registers the agent, gated on the old registration having been enabled — so an upgrade never - switches the login item *on* for someone who had it off. If the unregister - fails, the register still runs: a duplicate entry in System Settings is - cosmetic, whereas skipping it would leave a login launch that never sets - `--background`, which is the bug being fixed. + switches the login item *on* for someone who had it off. + + The attempt is recorded in a persisted flag + (`dezhban.loginItemMigratedToAgent`) and therefore happens at most once per + account, whether or not it worked. Inferring "already migrated" from a live + `mainApp.status` read instead — the first shape of this code — was only + truthful when the unregister had succeeded, and it can fail for real: a login + item added by hand in System Settings was never registered through + `SMAppService`, and unregister is also known to fail after the bundle moves. + The migration then re-ran on every launch and re-registered the agent after + the user had switched login-at-launch off in Settings, leaving it on with no + way to turn it off from the UI. + + When the legacy item survives the attempt — checked by reading its status + after, not by trusting the call not to throw — the agent is deliberately left + unregistered. Registering it anyway would mean two launches at login, the + agent with `--background` and the legacy item without, and whichever won the + race would decide whether the window appeared: worse than the behaviour it + replaces. Instead `LoginItem.isEnabled` reports the legacy registration too, + so the Settings toggle tells the truth about whether anything starts the app + at login, and switching it off retracts *both* — a user who toggles off and on + lands on a clean agent. - **The marker could be passed by something other than the agent**, making a user launch look like a login launch. The only consequence is a window that does not open, and the Dock icon and "Open Dezhban…" both open it diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 25ccbb6..9da0092 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -778,6 +778,31 @@ task gui:build && open dist/Dezhban.app "Close windows when quitting an application" *unchecked* in System Settings → Desktop & Dock, quit and relaunch in a mode that should open no window — it must stay closed. +- [ ] **Registering the login item does not leave two apps running.** With the + app up, switch Settings → "Open this app at login" **off then on**. The + agent's `RunAtLoad` execs a second copy the moment it registers, and + launchd does not dedupe the way LaunchServices did, so this is the check + that `yieldToRunningInstance()` works: exactly **one** menubar item and + one Dock tile afterwards, and `pgrep -x DezhbanMenu | wc -l` is 1. Repeat + immediately after an upgrade that runs the migration. +- [ ] **The login item is attributable and retractable.** System Settings → + General → Login Items shows the entry as **Dezhban**, not as + `com.behnam-rk.dezhban.app.login` (that is `AssociatedBundleIdentifiers` + doing its job — this is the switch a user reaches for to stop the app + starting at login, and it is useless if nobody can tell what it governs). + Then run `sudo sh /usr/local/share/dezhban/uninstall.sh` and confirm + `launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` fails and the + Login Items entry is gone — a per-user launchd agent does **not** go away + with its bundle the way a LaunchServices login item did. +- [ ] **The login agent registers from an ad-hoc-signed build.** Only reachable + on a real install: `build-app.sh` signs with `codesign -s -`, and + `SMAppService.agent` registration goes through launchd's validation of the + bundle, which `SMAppService.mainApp` never needed. If ad-hoc does not + satisfy it, login-at-launch fails as a silent `.notFound`/`.requiresApproval` + status rather than a crash — so check + `launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` on a build + installed the way users get it (`.pkg` or the app zip), not on + `dist/Dezhban.app` run in place. - [ ] **Posture tracking.** Drive the daemon with `--simulate-country IR` / `US` and confirm the menu bar icon *and* the Dock tile flip red/teal and the window's Overview updates within ~1 s. diff --git a/gui/macos/LoginAgent.plist b/gui/macos/LoginAgent.plist index 431f40b..8bc72ba 100644 --- a/gui/macos/LoginAgent.plist +++ b/gui/macos/LoginAgent.plist @@ -29,5 +29,13 @@ LimitLoadToSessionType Aqua + + AssociatedBundleIdentifiers + + com.behnam-rk.dezhban.app + diff --git a/gui/macos/Sources/DezhbanCore/SingleInstance.swift b/gui/macos/Sources/DezhbanCore/SingleInstance.swift new file mode 100644 index 0000000..f7f23ec --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/SingleInstance.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Which of several live copies of the app owns the session. +/// +/// This exists because the login item is a launchd agent (see `LaunchVisibility` +/// and docs/adr/0014-login-item-launch-marker.md). `SMAppService.mainApp` was a +/// LaunchServices entry, and LaunchServices refuses to start a second copy of a +/// bundle that is already running. An agent with `RunAtLoad` is not that: launchd +/// `exec`s `Contents/MacOS/DezhbanMenu` directly, which bypasses that dedupe +/// entirely. So `SMAppService.agent(...).register()` — called from the Settings +/// toggle and from the one-shot migration, both while the app is running — starts +/// a *second* app right then: two menubar items, two Dock tiles, two 1-second +/// state-file timers, two update checkers. `RunAtLoad` has to stay true or the +/// login launch never happens, so the duplicate is caught at startup instead. +/// +/// The rule is split out here, away from AppKit, because "both copies exit" is a +/// real way to get this wrong: two instances that each see the other and each +/// defer would leave the user with no app at all. So the comparison is a total +/// order over the candidates rather than a "does anyone else exist" test — +/// exactly one instance can be the smallest, so exactly one survives. +public struct InstanceIdentity: Sendable, Equatable { + /// The process id. Unique among live processes, which is what makes the + /// ordering below total even when two copies report the same launch date. + public let pid: Int32 + /// When the process started, as AppKit reports it. Optional because + /// `NSRunningApplication.launchDate` is documented to be nil when it cannot + /// be determined; an instance whose age is unknown sorts last and therefore + /// yields, which is the safe direction — the app that has been serving the + /// menubar keeps serving it. + public let launchedAt: Date? + + public init(pid: Int32, launchedAt: Date?) { + self.pid = pid + self.launchedAt = launchedAt + } + + /// Older wins; unknown age loses; pid breaks the tie. + fileprivate var rank: (Date, Int32) { (launchedAt ?? .distantFuture, pid) } +} + +public enum SingleInstance { + /// Whether this process should quit immediately because an equivalent copy + /// already owns the session. + /// + /// `others` must exclude this process. Returns true only when some other + /// candidate outranks us, so across any set of simultaneously-launched + /// copies exactly one gets false — pids are distinct, so the ordering admits + /// no ties and no cycles. + public static func shouldYield(own: InstanceIdentity, others: [InstanceIdentity]) -> Bool { + others.contains { other in + other.pid != own.pid && isBefore(other.rank, own.rank) + } + } + + private static func isBefore(_ lhs: (Date, Int32), _ rhs: (Date, Int32)) -> Bool { + if lhs.0 != rhs.0 { return lhs.0 < rhs.0 } + return lhs.1 < rhs.1 + } +} diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 3398d33..08449f2 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -11,59 +11,112 @@ import ServiceManagement /// agent's `ProgramArguments` carry `--background`, which is the only reliable /// way for the app to know that macOS started it rather than the user. See /// `LaunchVisibility` and docs/adr/0014-login-item-launch-marker.md. +/// +/// Registering an agent `exec`s the app immediately (`RunAtLoad`), so both +/// `toggle()` and the migration below can spawn a second copy of a running app. +/// That is caught at startup by `yieldToRunningInstance()` in main.swift, not +/// here — the duplicate is a *process* problem and this type has no way to see +/// it. enum LoginItem { /// Must match `LoginAgent.plist`'s `Label` and the filename build-app.sh /// installs it under; launchd rejects a mismatch, and SMAppService reports - /// it only as a `.notFound` status. + /// it only as a `.notFound` status. build-app.sh asserts the equality at + /// build time so this cannot drift silently. private static let plistName = "com.behnam-rk.dezhban.app.login.plist" + /// Whether the one-shot move off `SMAppService.mainApp` has been attempted. + /// + /// Persisted, and deliberately not re-derived from `mainApp.status`: that + /// read is only a truthful "already migrated" signal when the unregister + /// succeeded, and it can fail for real (a login item added by hand in System + /// Settings was never registered through SMAppService; unregister is also + /// known to fail after the bundle moves). Inferring it meant the migration + /// ran again on every launch, which re-registered the agent after the user + /// had switched login-at-launch off in Settings — login-at-launch back on, + /// with no way to turn it off from the UI. Same UserDefaults-flag call as + /// `FirstRun`: a fact about this app on this account, not daemon config. + private static let migratedKey = "dezhban.loginItemMigratedToAgent" + private static var service: SMAppService { .agent(plistName: plistName) } - static var isEnabled: Bool { - service.status == .enabled - } + private static var agentEnabled: Bool { service.status == .enabled } + + /// The legacy LaunchServices registration every build before the agent used. + private static var legacyEnabled: Bool { SMAppService.mainApp.status == .enabled } + + /// Whether anything at all will start this app at login — the agent or a + /// legacy registration the migration could not retract. + /// + /// Both, not just the agent: on the failed-migration path the legacy item is + /// still live, so the app still starts at login, and it starts *without* + /// `--background`, which is the very bug this PR fixes. Reporting only the + /// agent would show "off" while startup kept happening, and leave the user + /// no control that reaches the thing launching them. + static var isEnabled: Bool { agentEnabled || legacyEnabled } /// Toggles login-at-launch. Returns the resulting enabled state; on error it - /// logs and returns the unchanged prior state. + /// logs and returns whatever state the system is actually in. + /// + /// Turning it OFF retracts both registrations, for the reason `isEnabled` + /// reports both. Turning it ON registers only the agent — the legacy one is + /// never created again. @discardableResult static func toggle() -> Bool { - do { - if isEnabled { - try service.unregister() - } else { + if isEnabled { + if agentEnabled { unregister(service, what: "login agent") } + if legacyEnabled { unregister(.mainApp, what: "legacy login item") } + } else { + do { try service.register() + } catch { + NSLog("DezhbanMenu: could not register the login agent: \(error)") } - } catch { - NSLog("DezhbanMenu: login item toggle failed: \(error)") } return isEnabled } /// Moves an install that registered `SMAppService.mainApp` (every build - /// before the agent existed) onto the agent, once. + /// before the agent existed) onto the agent, exactly once per account. /// - /// Deliberately gated on the OLD registration being enabled: a user who had + /// Gated on the OLD registration being enabled: a user who had /// login-at-launch switched off must not have it switched on by an upgrade. - /// Idempotent — after the first run `mainApp.status` is no longer `.enabled`, - /// so this does nothing on every launch thereafter, and it never touches an - /// agent registration that already exists. + /// The attempt is recorded either way, so this runs at most once whether it + /// succeeded or not — see `migratedKey`. static func migrateFromMainAppRegistration() { - guard SMAppService.mainApp.status == .enabled else { return } + guard !UserDefaults.standard.bool(forKey: migratedKey) else { return } + defer { UserDefaults.standard.set(true, forKey: migratedKey) } + + guard legacyEnabled else { return } + unregister(.mainApp, what: "legacy login item") + + // Checked after the attempt rather than trusting it not to throw: what + // matters is whether the old item is actually gone. + if legacyEnabled { + // Leave the agent unregistered. Registering it now would mean TWO + // launches at login — the agent with `--background` and the legacy + // item without — and whichever won the race would decide whether the + // window appeared, which is worse than the old behaviour it would be + // replacing. `isEnabled` reports the legacy item, so the Settings + // toggle is honest, and switching it off and on again retracts the + // legacy registration and lands a clean agent. + NSLog("DezhbanMenu: the legacy login item could not be retracted; " + + "leaving login-at-launch as it was. Toggle it off and on in " + + "Settings to move onto the login agent.") + return + } + guard !agentEnabled else { return } do { - try SMAppService.mainApp.unregister() + try service.register() } catch { - NSLog("DezhbanMenu: could not unregister the legacy login item: \(error)") - // Fall through and register the agent anyway: two registrations is a - // cosmetic duplicate in System Settings, but skipping the register - // would leave the user with a login launch that never sets - // --background — the exact bug this migration exists to fix. + NSLog("DezhbanMenu: could not register the login agent: \(error)") } - if !isEnabled { - do { - try service.register() - } catch { - NSLog("DezhbanMenu: could not register the login agent: \(error)") - } + } + + private static func unregister(_ target: SMAppService, what: String) { + do { + try target.unregister() + } catch { + NSLog("DezhbanMenu: could not unregister the \(what): \(error)") } } } diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index 99be59e..a160717 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -1,4 +1,5 @@ import AppKit +import DezhbanCore /// A minimal programmatic main menu. Without one, the SwiftUI main window's /// text fields have no Edit menu (no ⌘C/⌘V/⌘X/⌘A) and ⌘W/⌘Q do nothing while @@ -51,11 +52,43 @@ func makeMainMenu() -> NSMenu { return main } +/// Quits at once if another copy of this bundle already owns the session. +/// +/// The login item is a launchd agent now, and `register()` on a `RunAtLoad` job +/// `exec`s the binary immediately — from the Settings toggle and from the +/// migration, both of which run while the app is up. launchd does not go through +/// LaunchServices, so nothing else dedupes it. Without this the user gets two +/// menubar items, and the duplicate carries `--background`, so under the default +/// "Only at login" it opens no window and there is no way to tell which icon is +/// which. See `SingleInstance` and docs/adr/0014-login-item-launch-marker.md. +/// +/// Only the loser exits — `SingleInstance.shouldYield` is a total order, so a +/// simultaneous pair cannot both stand down and leave the Mac with no app. +func yieldToRunningInstance() { + // No bundle identifier means a bare `swift run` binary, which LaunchServices + // does not track: nothing to compare against, and no agent to have spawned us. + guard let id = Bundle.main.bundleIdentifier else { return } + let mePID = ProcessInfo.processInfo.processIdentifier + let running = NSRunningApplication.runningApplications(withBundleIdentifier: id) + let others = running + .filter { $0.processIdentifier != mePID } + .map { InstanceIdentity(pid: $0.processIdentifier, launchedAt: $0.launchDate) } + guard !others.isEmpty else { return } + let own = InstanceIdentity( + pid: mePID, + launchedAt: NSRunningApplication.current.launchDate) + guard SingleInstance.shouldYield(own: own, others: others) else { return } + NSLog("DezhbanMenu: another instance is already running (pid \(others.map(\.pid))); exiting") + exit(0) +} + // Regular app (not an LSUIElement agent): the Dock tile doubles as a state // display — AppDelegate swaps NSApp.applicationIconImage to match the // enforcement posture, and that needs a Dock icon to exist. The bundled // Info.plist sets LSUIElement=false for the same reason. let app = NSApplication.shared +// Before the delegate installs a menubar item or a timer: see above. +yieldToRunningInstance() let delegate = AppDelegate() app.delegate = delegate app.setActivationPolicy(.regular) diff --git a/gui/macos/Tests/DezhbanCoreTests/SingleInstanceTests.swift b/gui/macos/Tests/DezhbanCoreTests/SingleInstanceTests.swift new file mode 100644 index 0000000..ee2c9b7 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/SingleInstanceTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import DezhbanCore + +struct SingleInstanceTests { + private func at(_ seconds: TimeInterval) -> Date { + Date(timeIntervalSince1970: 1_700_000_000 + seconds) + } + + /// The case that put this code here: registering the login agent execs a + /// second copy while the first is serving the menubar. The newcomer yields. + @Test func theNewcomerYieldsToTheAppAlreadyRunning() { + let running = InstanceIdentity(pid: 100, launchedAt: at(0)) + let spawned = InstanceIdentity(pid: 900, launchedAt: at(60)) + #expect(SingleInstance.shouldYield(own: spawned, others: [running])) + #expect(!SingleInstance.shouldYield(own: running, others: [spawned])) + } + + /// The failure this ordering exists to prevent: if the rule were merely + /// "does anyone else exist", two copies racing at login would both stand + /// down and the Mac would end up with no app at all. Exactly one survives. + @Test func exactlyOneSurvivesWhenEveryCopyAsksAtOnce() { + let same = at(0) + let copies = [ + InstanceIdentity(pid: 300, launchedAt: same), + InstanceIdentity(pid: 100, launchedAt: same), + InstanceIdentity(pid: 200, launchedAt: same), + ] + let survivors = copies.filter { own in + !SingleInstance.shouldYield(own: own, others: copies.filter { $0.pid != own.pid }) + } + #expect(survivors.count == 1) + #expect(survivors.first?.pid == 100) + } + + /// `NSRunningApplication.launchDate` is documented as optional. An instance + /// whose age is unknown must lose, so the copy already serving the menubar + /// keeps serving it rather than being displaced by a newcomer AppKit could + /// not date. + @Test func unknownAgeYields() { + let dated = InstanceIdentity(pid: 500, launchedAt: at(10)) + let undated = InstanceIdentity(pid: 100, launchedAt: nil) + #expect(SingleInstance.shouldYield(own: undated, others: [dated])) + #expect(!SingleInstance.shouldYield(own: dated, others: [undated])) + } + + /// Two undated copies still resolve — pid breaks the tie, so this cannot + /// degrade into both-exit either. + @Test func twoUndatedCopiesStillResolve() { + let a = InstanceIdentity(pid: 100, launchedAt: nil) + let b = InstanceIdentity(pid: 200, launchedAt: nil) + #expect(!SingleInstance.shouldYield(own: a, others: [b])) + #expect(SingleInstance.shouldYield(own: b, others: [a])) + } + + /// The ordinary case — one app, nothing to yield to. + @Test func aLoneInstanceNeverYields() { + let only = InstanceIdentity(pid: 100, launchedAt: at(0)) + #expect(!SingleInstance.shouldYield(own: only, others: [])) + } + + /// A caller that fails to exclude this process from `others` must not make + /// the app quit on every launch. + @Test func seeingItselfInTheListIsNotADuplicate() { + let me = InstanceIdentity(pid: 100, launchedAt: at(0)) + #expect(!SingleInstance.shouldYield(own: me, others: [me])) + } +} diff --git a/gui/macos/build-app.sh b/gui/macos/build-app.sh index 2c6e4af..4791687 100755 --- a/gui/macos/build-app.sh +++ b/gui/macos/build-app.sh @@ -78,11 +78,37 @@ install -m 0644 "$REPO_ROOT/LICENSE" "$APP/Contents/Resources/LICENSE" # bundle assembled without it registers nothing and the app silently stops # starting at login, so its absence is a build failure rather than a note. # See docs/adr/0014-login-item-launch-marker.md. +AGENT_LABEL="com.behnam-rk.dezhban.app.login" +AGENT_PLIST="$APP/Contents/Library/LaunchAgents/$AGENT_LABEL.plist" mkdir -p "$APP/Contents/Library/LaunchAgents" -install -m 0644 "$HERE/LoginAgent.plist" \ - "$APP/Contents/Library/LaunchAgents/com.behnam-rk.dezhban.app.login.plist" -if [[ ! -f "$APP/Contents/Library/LaunchAgents/com.behnam-rk.dezhban.app.login.plist" ]]; then - echo "build-app.sh: the login LaunchAgent did not land in the bundle — the app would not start at login" >&2 +install -m 0644 "$HERE/LoginAgent.plist" "$AGENT_PLIST" + +# The two facts that make the feature work, asserted rather than commented. +# `install` under `set -e` already aborts if the file does not land, so its +# existence needs no test — but deleting --background from LoginAgent.plist, or +# renaming its Label, passes go test, swift test and this build while silently +# restoring the original bug (or killing login-at-launch outright, reported only +# as an SMAppService status nobody reads). +# +# 1. Label must equal the filename SMAppService.agent(plistName:) is given +# (LoginItem.plistName) — launchd rejects a mismatch. +plist_label="$(plutil -extract Label raw -o - "$AGENT_PLIST")" +if [[ "$plist_label" != "$AGENT_LABEL" ]]; then + echo "build-app.sh: LoginAgent.plist Label is '$plist_label', but it is installed as '$AGENT_LABEL.plist' — launchd would reject the job and the app would not start at login" >&2 + exit 1 +fi +# 2. ProgramArguments must still carry the launch marker LaunchVisibility keys on +# (LaunchVisibility.backgroundArgument), or every login looks like a user +# launch and "Open minimized" silently stops working. +agent_argc="$(plutil -extract ProgramArguments raw -o - "$AGENT_PLIST")" +agent_marker=0 +for ((i = 0; i < agent_argc; i++)); do + if [[ "$(plutil -extract "ProgramArguments.$i" raw -o - "$AGENT_PLIST")" == "--background" ]]; then + agent_marker=1 + fi +done +if [[ "$agent_marker" -ne 1 ]]; then + echo "build-app.sh: LoginAgent.plist ProgramArguments no longer pass --background — a login launch would be indistinguishable from a user launch" >&2 exit 1 fi diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 1361ae5..5b9789a 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -20,6 +20,7 @@ CONFIG_DIR=/etc/dezhban STATE_DIR=/var/db/dezhban PLIST=/Library/LaunchDaemons/dezhban.plist SHARE_DIR=/usr/local/share/dezhban +LOGIN_AGENT=com.behnam-rk.dezhban.app.login if [ "$(id -u)" -ne 0 ]; then echo "error: run as root — sudo sh $0" >&2 @@ -46,6 +47,22 @@ echo "removing the menubar app ..." # bundle out from under a live process. osascript -e 'tell application "Dezhban" to quit' >/dev/null 2>&1 || true pkill -x DezhbanMenu >/dev/null 2>&1 || true + +# The login item is a per-user launchd agent (SMAppService.agent), NOT a +# LaunchServices entry: deleting the bundle does not retract it. Left registered it +# fails to load at every subsequent login and lingers in System Settings → General +# → Login Items as an orphan the user has to hunt down. Booting it out needs that +# user's own GUI session, so root can only reach the console user — other accounts +# get the one command they need, printed at the end. +CONSOLE_USER=$(stat -f %Su /dev/console 2>/dev/null || echo "") +if [ -n "$CONSOLE_USER" ] && [ "$CONSOLE_USER" != "root" ]; then + CONSOLE_UID=$(id -u "$CONSOLE_USER" 2>/dev/null || echo "") + if [ -n "$CONSOLE_UID" ]; then + echo "unregistering the login agent for $CONSOLE_USER ..." + launchctl bootout "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1 || true + fi +fi + rm -rf "$APP" # The daemon's own directory: state.json, learned.json, the command file and the @@ -73,3 +90,8 @@ rm -rf "$SHARE_DIR" echo echo "dezhban uninstalled — rules removed, service unregistered, files deleted." +echo +echo "If any OTHER account on this Mac ran the app, its login agent is still" +echo "registered there — root cannot reach another user's launchd session. From" +echo "that account, once:" +echo " launchctl bootout gui/\$(id -u)/$LOGIN_AGENT" From ff66ba9369528baebb71be7665eb58e423f4586f Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Fri, 21 Aug 2026 22:16:14 +0330 Subject: [PATCH 03/36] fix(gui): hold a lock, not an opinion about who launched first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round found the duplicate-instance guard unsound, and it was: comparing launch dates cannot work here, because only a newly started process ever evaluates the question. The copy already serving the menubar never re-decides anything, so any rule under which the newcomer might think it wins leaves both running, and any rule under which an undatable process yields can retire both and leave the Mac with no app. NSRunningApplication.launchDate is documented as optional, so both were reachable, and the test I wrote pinned the wrong direction. Replaced with an exclusive flock: exactly one open file description holds it, and the kernel drops it when that process dies however it dies, so a crashed predecessor cannot lock its successor out. Keyed on the bundle path, not its identifier, so dist/Dezhban.app run against an installed copy — the documented GUI dev loop — is not treated as a duplicate and silently exited, which would have made every manual check on the list test the installed binary instead. A launch the user performed must not become a silent no-op either. The copy that loses the lock now focuses the winner and posts a distributed notification asking it to open its window, since the incumbent may be a --background login launch with no window to hand over. A notification rather than re-opening the bundle through NSWorkspace, which could spawn yet another copy that finds the lock held and asks again. launchctl bootout did not retract the registration it claimed to. It unloads the job for the current boot and leaves the record that recreates it at the next login, pointing into a bundle the script then deletes — precisely the orphan it was added to prevent. Only SMAppService can retract it and only the app can call that, so DezhbanMenu takes a --unregister-login-item errand flag, handled before the instance lock, and uninstall.sh runs it as the console user inside their GUI session before deleting anything. The advertised recovery from a stuck legacy login item was unreachable: toggle() branches on isEnabled, which the stuck item holds true, so every attempt took the off branch and could never reach register(). toggle() now returns an Outcome instead of a Bool, naming the one thing that does work — remove the item in System Settings — and also distinguishing "macOS is holding this for your approval", which register() reports as a status rather than an error and which otherwise looked like a switch that refuses to stay on. Also: the label assertion only tied the plist to build-app.sh's own hardcoded copy, so a consistent rename passed every check while SMAppService named a file that does not exist. It now greps LoginItem.swift and uninstall.sh too. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 22 +-- docs/adr/0014-login-item-launch-marker.md | 66 +++++++-- docs/contribute/testing.md | 33 +++-- .../Sources/DezhbanCore/InstanceLock.swift | 110 +++++++++++++++ .../Sources/DezhbanCore/SingleInstance.swift | 59 -------- .../Sources/DezhbanMenu/AppDelegate.swift | 17 +++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 129 ++++++++++++++---- .../Sources/DezhbanMenu/SettingsView.swift | 11 +- gui/macos/Sources/DezhbanMenu/main.swift | 100 +++++++++++--- .../DezhbanCoreTests/InstanceLockTests.swift | 122 +++++++++++++++++ .../SingleInstanceTests.swift | 68 --------- gui/macos/build-app.sh | 11 ++ packaging/macos/uninstall.sh | 31 +++-- 13 files changed, 558 insertions(+), 221 deletions(-) create mode 100644 gui/macos/Sources/DezhbanCore/InstanceLock.swift delete mode 100644 gui/macos/Sources/DezhbanCore/SingleInstance.swift create mode 100644 gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift delete mode 100644 gui/macos/Tests/DezhbanCoreTests/SingleInstanceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b196d6..96bd483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,15 +25,19 @@ current as you land changes. consulting the setting at all. Existing installs are migrated once, on first launch; if you had login-at-launch switched off, it stays off. - Two consequences of the mechanism, handled in the same change. A launchd agent - starts the moment it is registered and — unlike the LaunchServices login item - it replaces — does not check whether the app is already running, so turning - "Open this app at login" on used to be able to leave you with two menubar - icons; a duplicate copy now exits at startup. And a launchd registration does - not disappear with the app bundle the way a login item did, so `uninstall.sh` - retracts it rather than leaving an entry that fails to load at every - subsequent login. The Login Items entry also reads "Dezhban" now instead of a - raw job label. + Three consequences of the mechanism, handled in the same change. A launchd + agent starts the moment it is registered and — unlike the LaunchServices login + item it replaces — does not check whether the app is already running, so + turning "Open this app at login" on could leave you with two menubar icons; a + duplicate copy now exits at startup, and if you started it yourself it brings + the running copy forward with its window open rather than appearing to do + nothing. A launchd registration also does not disappear with the app bundle + the way a login item did, so `uninstall.sh` now has the app retract it before + deleting anything, instead of leaving an entry that fails to load at every + subsequent login; the Login Items entry reads "Dezhban" now instead of a raw + job label. And the login toggle says what actually happened — including when + macOS is holding the registration for your approval, and when it refuses to + remove the old login item and only you can clear it in System Settings. ## [0.11.0] - 2026-08-21 diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index f48ac60..f43f589 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -104,17 +104,42 @@ with an argument, the pre-`SMAppService` pattern. `--background`, so under the default `bootOnly` it opens no window and there is no way to tell the two icons apart. `RunAtLoad` has to stay true or the login launch never happens, so the duplicate is caught at startup instead: - `yieldToRunningInstance()` in `main.swift` exits before the delegate installs - anything. The comparison is a total order over (launch date, pid) rather than - a "does anyone else exist" test, because two copies that each saw the other - and each stood down would leave the Mac with no app at all — see - `SingleInstance`. -- **Uninstalling has to retract the registration.** A LaunchServices login item - disappears with its bundle; a per-user launchd agent does not. Left behind it - fails to load at every subsequent login and lingers in System Settings as an - orphan job. `packaging/macos/uninstall.sh` boots it out for the console user - and prints the one command other accounts need, since root cannot reach - another user's launchd session. + `acquireSessionOwnership()` in `main.swift` takes an exclusive `flock` before + `NSApplication` exists, and a process that cannot get it exits. + + A lock rather than "which copy launched first", which is what this was first + written as and which cannot work. Only a *newly started* process ever + evaluates the question — the copy already serving the menubar never + re-evaluates anything — so any rule under which the newcomer might decide it + wins leaves both running, and any rule under which an undatable process yields + can retire both and leave the Mac with no app at all. + `NSRunningApplication.launchDate` is documented as optional, so both failures + were reachable. The kernel has neither problem: exactly one open file + description holds the lock, and it is released when that process dies however + it dies, so a crashed predecessor cannot lock its successor out. The lock is + keyed on the bundle's **path**, not its identifier, because + `dist/Dezhban.app` run against an installed `/Applications/Dezhban.app` is the + documented GUI dev loop and those two are not duplicates of each other. + + A launch the *user* performed must never become a silent no-op, so the copy + that loses the lock focuses the winner and posts a distributed notification + asking it to open its window — the incumbent may be a `--background` login + launch with no window to be handed over to. A notification rather than + re-opening the bundle through `NSWorkspace`: asking LaunchServices to open the + app we are in the middle of quitting could spawn yet another copy, which would + find the lock held and ask again. +- **Uninstalling has to retract the registration, and only the app can.** A + LaunchServices login item disappears with its bundle; a per-user launchd agent + does not. `launchctl bootout` is not the answer either — it unloads the job for + the current boot and leaves the record that recreates it at the next login, + pointing at a plist inside a bundle that has been deleted, which is exactly the + orphan being avoided. `SMAppService.unregister()` is the only real retraction + and it can only be called by the app, so `DezhbanMenu` takes a + `--unregister-login-item` errand flag — handled before the instance lock, since + it is not a second copy competing for the session — and + `packaging/macos/uninstall.sh` runs it as the console user inside their GUI + session before deleting the bundle. Root cannot reach another account's launchd + session, so other users' entries are named in the closing message instead. ### Risks @@ -139,10 +164,21 @@ with an argument, the pre-`SMAppService` pattern. unregistered. Registering it anyway would mean two launches at login, the agent with `--background` and the legacy item without, and whichever won the race would decide whether the window appeared: worse than the behaviour it - replaces. Instead `LoginItem.isEnabled` reports the legacy registration too, - so the Settings toggle tells the truth about whether anything starts the app - at login, and switching it off retracts *both* — a user who toggles off and on - lands on a clean agent. + replaces. `LoginItem.isEnabled` reports the legacy registration too, so the + Settings toggle tells the truth about whether anything starts the app at + login, and switching it off retracts *both*. + + If macOS keeps refusing to retract it, the app has no way out on its own, and + it must not pretend otherwise: "toggle it off and on again" was the first + advice here and it was unreachable, because `toggle()` branches on `isEnabled`, + which the stuck legacy item holds true — so every attempt took the *off* branch + and could never reach `register()`. `LoginItem.toggle()` therefore returns an + `Outcome` rather than a `Bool`, and the `legacyStuck` case tells the user the + one thing that does work: remove "Dezhban" under System Settings → General → + Login Items. Once they do, the toggle registers a clean agent. The same + `Outcome` carries `awaitingApproval`, because `register()` reports "the user + must approve this in System Settings" as a *status* rather than an error, and a + switch that snaps back with no explanation is indistinguishable from a bug. - **The marker could be passed by something other than the agent**, making a user launch look like a login launch. The only consequence is a window that does not open, and the Dock icon and "Open Dezhban…" both open it diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 9da0092..b9c1996 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -782,18 +782,35 @@ task gui:build && open dist/Dezhban.app app up, switch Settings → "Open this app at login" **off then on**. The agent's `RunAtLoad` execs a second copy the moment it registers, and launchd does not dedupe the way LaunchServices did, so this is the check - that `yieldToRunningInstance()` works: exactly **one** menubar item and - one Dock tile afterwards, and `pgrep -x DezhbanMenu | wc -l` is 1. Repeat + that the instance lock works: exactly **one** menubar item and one Dock + tile afterwards, and `pgrep -x DezhbanMenu | wc -l` is 1. Repeat immediately after an upgrade that runs the migration. -- [ ] **The login item is attributable and retractable.** System Settings → - General → Login Items shows the entry as **Dezhban**, not as +- [ ] **A user launch that loses the lock is not a silent no-op.** With the app + running from a `--background` login launch (so it has no window), launch it + again from Finder. The second copy must exit *and* the first must come + forward with its window open — that is the distributed notification in + `acquireSessionOwnership()`. Doing nothing at all here is a worse bug than + the duplicate icon this check's predecessor covers. +- [ ] **The dev build is not deduped against the installed one.** With + `/Applications/Dezhban.app` running, `task gui:build && open + dist/Dezhban.app`. Both must run — the lock is keyed on the bundle path + precisely so every other manual check on this list tests the build you just + made rather than silently testing the installed copy. +- [ ] **The login item is attributable.** System Settings → General → Login + Items shows the entry as **Dezhban**, not as `com.behnam-rk.dezhban.app.login` (that is `AssociatedBundleIdentifiers` doing its job — this is the switch a user reaches for to stop the app starting at login, and it is useless if nobody can tell what it governs). - Then run `sudo sh /usr/local/share/dezhban/uninstall.sh` and confirm - `launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` fails and the - Login Items entry is gone — a per-user launchd agent does **not** go away - with its bundle the way a LaunchServices login item did. +- [ ] **Uninstall retracts the registration, not just the running job.** With + login-at-launch on, run `sudo sh /usr/local/share/dezhban/uninstall.sh`, + then confirm `launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` + fails **and** the System Settings → General → Login Items entry is gone, + **and** that it is still gone after a reboot. A per-user launchd agent does + not go away with its bundle the way a LaunchServices login item did, and + `launchctl bootout` alone only unloads it for the current boot — the + reboot is what distinguishes a real retraction (the + `--unregister-login-item` errand the script runs as the console user) from + an unload that comes back. - [ ] **The login agent registers from an ad-hoc-signed build.** Only reachable on a real install: `build-app.sh` signs with `codesign -s -`, and `SMAppService.agent` registration goes through launchd's validation of the diff --git a/gui/macos/Sources/DezhbanCore/InstanceLock.swift b/gui/macos/Sources/DezhbanCore/InstanceLock.swift new file mode 100644 index 0000000..2d6e3af --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/InstanceLock.swift @@ -0,0 +1,110 @@ +import Foundation + +/// An advisory `flock(2)` on a per-install lock file: whoever holds it owns this +/// login session, and a second copy of the same app exits. +/// +/// This exists because the login item is a launchd agent (see `LaunchVisibility` +/// and docs/adr/0014-login-item-launch-marker.md). `SMAppService.mainApp` was a +/// LaunchServices entry, and LaunchServices refuses to start a second copy of a +/// bundle that is already running. An agent with `RunAtLoad` is not that: launchd +/// `exec`s `Contents/MacOS/DezhbanMenu` directly, bypassing that dedupe. So +/// `SMAppService.agent(...).register()` — called from the Settings toggle and from +/// the one-shot migration, both while the app is running — starts a *second* app +/// right then: two menubar items, two Dock tiles, two 1-second state-file timers, +/// two update checkers. `RunAtLoad` has to stay true or the login launch never +/// happens, so the duplicate is caught at startup instead. +/// +/// A lock rather than a comparison of the running copies, which is what this was +/// first written as. Comparing "who launched earlier" cannot work here: only a +/// *newly started* process ever evaluates the question, and the copy already +/// serving the menubar never re-evaluates anything. Any rule under which the +/// newcomer might decide it wins leaves both running, and any rule under which an +/// undatable process yields can — with two copies racing — retire both and leave +/// the Mac with no app at all. `NSRunningApplication.launchDate` is documented as +/// optional, so both failures were reachable. The kernel has none of these +/// problems: exactly one open file description holds an exclusive `flock`, and it +/// is released when that process dies, however it dies, so a crashed or +/// force-quit predecessor cannot lock its successor out. +/// +/// Keyed on the bundle's **path**, not its identifier. Two builds of the same app +/// in different places are not duplicates of each other — `dist/Dezhban.app` run +/// against an installed `/Applications/Dezhban.app` is the documented GUI dev loop +/// (docs/contribute/testing.md), and an identifier-scoped lock would have made the +/// freshly built copy exit on launch and silently test the installed one instead. +public final class InstanceLock { + public enum Acquisition: Equatable { + /// This process now owns the session and holds the lock until it exits. + case acquired + /// Another live process of the same install holds it. + case heldByAnother + /// The lock file could not be opened at all (unwritable directory, and so + /// on). Treated as `acquired` by callers: refusing to start because a + /// cache directory is broken would be a worse bug than a duplicate icon. + case unavailable(String) + } + + /// Where the lock file lives. + public let url: URL + + /// Held for the process's lifetime once acquired. Never closed on purpose — + /// see `release()`. + private var fd: Int32 = -1 + + public init(url: URL) { + self.url = url + } + + /// The conventional location: one file per install, under the user's caches. + /// + /// `bundlePath` is hashed rather than embedded so the name cannot exceed a + /// filename limit, and hashed with FNV-1a rather than `hashValue` because + /// Swift's is seeded per process — two copies of the app must derive the + /// *same* name, which a randomly seeded hash would not give them. + public static func forBundle(path bundlePath: String, + identifier: String, + cachesDirectory: URL) -> InstanceLock { + let dir = cachesDirectory.appendingPathComponent(identifier, isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let name = "instance-" + String(fnv1a(bundlePath), radix: 16) + ".lock" + return InstanceLock(url: dir.appendingPathComponent(name)) + } + + /// FNV-1a, 64-bit. Deterministic across processes and OS versions, which is + /// the only property required of it here — this is a name, not a digest. + static func fnv1a(_ s: String) -> UInt64 { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in s.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 + } + return hash + } + + public func acquire() -> Acquisition { + guard fd < 0 else { return .acquired } + let opened = open(url.path, O_CREAT | O_RDWR, 0o644) + if opened < 0 { + return .unavailable("open(\(url.path)): \(String(cString: strerror(errno)))") + } + // LOCK_NB: never block. A blocking wait would hang the launch behind a + // process that is not going to exit. + if flock(opened, LOCK_EX | LOCK_NB) != 0 { + let err = errno + close(opened) + if err == EWOULDBLOCK { return .heldByAnother } + return .unavailable("flock(\(url.path)): \(String(cString: strerror(err)))") + } + fd = opened + return .acquired + } + + /// Drops the lock. Only tests need this: a real process holds the lock until + /// it exits, and the kernel releases it then — including on a crash, which is + /// the whole reason for using a lock rather than a pid file. + public func release() { + guard fd >= 0 else { return } + flock(fd, LOCK_UN) + close(fd) + fd = -1 + } +} diff --git a/gui/macos/Sources/DezhbanCore/SingleInstance.swift b/gui/macos/Sources/DezhbanCore/SingleInstance.swift deleted file mode 100644 index f7f23ec..0000000 --- a/gui/macos/Sources/DezhbanCore/SingleInstance.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -/// Which of several live copies of the app owns the session. -/// -/// This exists because the login item is a launchd agent (see `LaunchVisibility` -/// and docs/adr/0014-login-item-launch-marker.md). `SMAppService.mainApp` was a -/// LaunchServices entry, and LaunchServices refuses to start a second copy of a -/// bundle that is already running. An agent with `RunAtLoad` is not that: launchd -/// `exec`s `Contents/MacOS/DezhbanMenu` directly, which bypasses that dedupe -/// entirely. So `SMAppService.agent(...).register()` — called from the Settings -/// toggle and from the one-shot migration, both while the app is running — starts -/// a *second* app right then: two menubar items, two Dock tiles, two 1-second -/// state-file timers, two update checkers. `RunAtLoad` has to stay true or the -/// login launch never happens, so the duplicate is caught at startup instead. -/// -/// The rule is split out here, away from AppKit, because "both copies exit" is a -/// real way to get this wrong: two instances that each see the other and each -/// defer would leave the user with no app at all. So the comparison is a total -/// order over the candidates rather than a "does anyone else exist" test — -/// exactly one instance can be the smallest, so exactly one survives. -public struct InstanceIdentity: Sendable, Equatable { - /// The process id. Unique among live processes, which is what makes the - /// ordering below total even when two copies report the same launch date. - public let pid: Int32 - /// When the process started, as AppKit reports it. Optional because - /// `NSRunningApplication.launchDate` is documented to be nil when it cannot - /// be determined; an instance whose age is unknown sorts last and therefore - /// yields, which is the safe direction — the app that has been serving the - /// menubar keeps serving it. - public let launchedAt: Date? - - public init(pid: Int32, launchedAt: Date?) { - self.pid = pid - self.launchedAt = launchedAt - } - - /// Older wins; unknown age loses; pid breaks the tie. - fileprivate var rank: (Date, Int32) { (launchedAt ?? .distantFuture, pid) } -} - -public enum SingleInstance { - /// Whether this process should quit immediately because an equivalent copy - /// already owns the session. - /// - /// `others` must exclude this process. Returns true only when some other - /// candidate outranks us, so across any set of simultaneously-launched - /// copies exactly one gets false — pids are distinct, so the ordering admits - /// no ties and no cycles. - public static func shouldYield(own: InstanceIdentity, others: [InstanceIdentity]) -> Bool { - others.contains { other in - other.pid != own.pid && isBefore(other.rank, own.rank) - } - } - - private static func isBefore(_ lhs: (Date, Int32), _ rhs: (Date, Int32)) -> Bool { - if lhs.0 != rhs.0 { return lhs.0 < rhs.0 } - return lhs.1 < rhs.1 - } -} diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 0550644..2ac69d1 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -32,6 +32,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// a thing to hammer GitHub with. See UpdateChecker's doc comment. private static let updateCheckInterval: TimeInterval = 24 * 60 * 60 + /// Posted by a duplicate copy of the app as it exits, when the user started + /// it themselves (see `acquireSessionOwnership` in main.swift). Without it a + /// user-initiated launch that loses the instance lock would do visibly + /// nothing at all — and the copy that owns the session may be a + /// `--background` login launch with no window to be handed over to. + static let openWindowNotification = "com.behnam-rk.dezhban.app.openWindow" + func applicationDidFinishLaunching(_: Notification) { NotificationManager.requestAuthorizationIfNeeded() // Resolve the config path once, off the main thread, before any pane asks for @@ -76,6 +83,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if LaunchPreference.current.opensWindow(backgroundLaunch: backgroundLaunch) { MainWindow.shared.open() } + DistributedNotificationCenter.default().addObserver( + self, selector: #selector(openWindowRequested), + name: NSNotification.Name(Self.openWindowNotification), object: nil) AppState.shared.refreshServiceState() AppState.shared.checkForUpdates() AppState.shared.offerFirstRunIfNeeded() @@ -87,6 +97,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + /// A second copy of the app was started by the user and found this one + /// already owning the session. Opening the window is the whole reason it + /// bothered to tell us — it is standing in for the launch the user performed. + @objc private func openWindowRequested() { + MainWindow.shared.open() + } + /// Clicking the Dock icon (re)opens the main window — the standard macOS /// contract for a regular app whose windows are all closed. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 08449f2..b896c0a 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -14,14 +14,13 @@ import ServiceManagement /// /// Registering an agent `exec`s the app immediately (`RunAtLoad`), so both /// `toggle()` and the migration below can spawn a second copy of a running app. -/// That is caught at startup by `yieldToRunningInstance()` in main.swift, not -/// here — the duplicate is a *process* problem and this type has no way to see -/// it. +/// That is caught at startup by the instance lock in main.swift, not here — the +/// duplicate is a *process* problem and this type has no way to see it. enum LoginItem { /// Must match `LoginAgent.plist`'s `Label` and the filename build-app.sh /// installs it under; launchd rejects a mismatch, and SMAppService reports - /// it only as a `.notFound` status. build-app.sh asserts the equality at - /// build time so this cannot drift silently. + /// it only as a `.notFound` status. build-app.sh greps this file for the + /// label it installs, so the three cannot drift apart silently. private static let plistName = "com.behnam-rk.dezhban.app.login.plist" /// Whether the one-shot move off `SMAppService.mainApp` has been attempted. @@ -37,6 +36,53 @@ enum LoginItem { /// `FirstRun`: a fact about this app on this account, not daemon config. private static let migratedKey = "dezhban.loginItemMigratedToAgent" + /// What a `toggle()` actually achieved, so the UI can say something true. + /// + /// A plain `Bool` could not: `register()` reports "the user has to approve + /// this in System Settings" as a *status* rather than an error, and the one + /// path where the legacy registration cannot be retracted needs its own + /// message because the app has no way out of it on its own. + enum Outcome: Equatable { + /// Login-at-launch is on, via the agent. + case enabled + /// Nothing will start the app at login. + case disabled + /// Registered, but macOS is holding it for the user's approval — most + /// often because they switched this app off in System Settings before. + case awaitingApproval + /// The legacy LaunchServices login item is still live and macOS refuses + /// to retract it, so the app still starts at login without the launch + /// marker. Only the user can clear this, in System Settings. + case legacyStuck + /// Registration failed outright. + case failed(String) + + /// Whether anything starts the app at login — what the Settings switch + /// shows. + var isOn: Bool { + switch self { + case .enabled, .awaitingApproval, .legacyStuck: return true + case .disabled, .failed: return false + } + } + + /// One line for the Settings status area. + var message: String { + switch self { + case .enabled: return "App will open at login." + case .disabled: return "App will not open at login." + case .awaitingApproval: + return "macOS is holding this for your approval — enable Dezhban in " + + "System Settings → General → Login Items." + case .legacyStuck: + return "macOS would not remove the old login item. Remove \"Dezhban\" under " + + "System Settings → General → Login Items, then switch this on again." + case .failed(let why): + return "Could not change the login item: \(why)" + } + } + } + private static var service: SMAppService { .agent(plistName: plistName) } private static var agentEnabled: Bool { service.status == .enabled } @@ -54,25 +100,56 @@ enum LoginItem { /// no control that reaches the thing launching them. static var isEnabled: Bool { agentEnabled || legacyEnabled } - /// Toggles login-at-launch. Returns the resulting enabled state; on error it - /// logs and returns whatever state the system is actually in. + /// Toggles login-at-launch and reports what actually happened. /// /// Turning it OFF retracts both registrations, for the reason `isEnabled` /// reports both. Turning it ON registers only the agent — the legacy one is /// never created again. @discardableResult - static func toggle() -> Bool { - if isEnabled { - if agentEnabled { unregister(service, what: "login agent") } - if legacyEnabled { unregister(.mainApp, what: "legacy login item") } - } else { - do { - try service.register() - } catch { - NSLog("DezhbanMenu: could not register the login agent: \(error)") - } + static func toggle() -> Outcome { + if isEnabled { return disable() } + return enable() + } + + private static func enable() -> Outcome { + do { + try service.register() + } catch { + NSLog("DezhbanMenu: could not register the login agent: \(error)") + return .failed(error.localizedDescription) } - return isEnabled + // Checked, not assumed: `register()` returns without throwing when macOS + // is going to make the user approve it, and the switch snapping back with + // no explanation is indistinguishable from a bug. + if service.status == .requiresApproval { return .awaitingApproval } + return agentEnabled ? .enabled : .failed("macOS reported the login item as \(service.status)") + } + + private static func disable() -> Outcome { + if agentEnabled { unregister(service, what: "login agent") } + if legacyEnabled { unregister(.mainApp, what: "legacy login item") } + if legacyEnabled { + // The stuck path. Reported rather than worked around: registering the + // agent alongside it would mean two launches at login, one with the + // marker and one without, and whichever won the race would decide + // whether the window appeared. `Outcome.legacyStuck` tells the user + // the one thing that does clear it. + NSLog("DezhbanMenu: the legacy login item could not be retracted") + return .legacyStuck + } + return agentEnabled ? .failed("the login agent is still registered") : .disabled + } + + /// Retracts everything that could start this app at login, best effort. + /// + /// Used by the `--unregister-login-item` errand the uninstaller runs (see + /// main.swift). `SMAppService.unregister()` is the only thing that actually + /// retracts an agent registration — `launchctl bootout` unloads the job for + /// this boot and leaves the record that recreates it at the next login — and + /// only the app can call it. + static func retractAll() { + if agentEnabled { unregister(service, what: "login agent") } + if legacyEnabled { unregister(.mainApp, what: "legacy login item") } } /// Moves an install that registered `SMAppService.mainApp` (every build @@ -92,16 +169,14 @@ enum LoginItem { // Checked after the attempt rather than trusting it not to throw: what // matters is whether the old item is actually gone. if legacyEnabled { - // Leave the agent unregistered. Registering it now would mean TWO - // launches at login — the agent with `--background` and the legacy - // item without — and whichever won the race would decide whether the - // window appeared, which is worse than the old behaviour it would be - // replacing. `isEnabled` reports the legacy item, so the Settings - // toggle is honest, and switching it off and on again retracts the - // legacy registration and lands a clean agent. + // Same reasoning as `disable()`'s stuck path — the agent is left + // unregistered rather than stacked on top of a live legacy item. The + // Settings toggle reports the legacy registration, so the user can + // see login-at-launch is on; clearing it is a System Settings job, + // which `Outcome.legacyStuck` spells out when they try. NSLog("DezhbanMenu: the legacy login item could not be retracted; " - + "leaving login-at-launch as it was. Toggle it off and on in " - + "Settings to move onto the login agent.") + + "leaving login-at-launch as it was. Remove \"Dezhban\" under " + + "System Settings → General → Login Items to move onto the login agent.") return } guard !agentEnabled else { return } diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 63d6764..c5c2b8b 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -781,10 +781,13 @@ struct SettingsView: View { Binding( get: { loginEnabled }, set: { _ in - loginEnabled = LoginItem.toggle() - status = loginEnabled - ? "App will open at login." - : "App will not open at login." + // The outcome, not a bool: macOS can accept the registration and + // still hold it for the user's approval, and there is one path + // where only they can clear the old login item. A switch that + // snaps back with no explanation reads as a bug. + let outcome = LoginItem.toggle() + loginEnabled = outcome.isOn + status = outcome.message }) } diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index a160717..04b08d9 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -52,7 +52,25 @@ func makeMainMenu() -> NSMenu { return main } -/// Quits at once if another copy of this bundle already owns the session. +/// Retracts every login registration and exits, without starting the app. +/// +/// The uninstaller needs this. A LaunchServices login item disappeared with its +/// bundle; the launchd agent that replaced it does not — and `launchctl bootout` +/// only unloads the job for this boot, leaving the registration that created it +/// to reload at the next login against a plist inside a bundle that has been +/// deleted. Only `SMAppService.unregister()` actually retracts it, and only the +/// app can call it, so `packaging/macos/uninstall.sh` runs the binary this way +/// (as the console user, in their GUI session) before deleting anything. +/// +/// Handled before the instance lock, deliberately: this is not a second copy of +/// the app competing for the session, it is a one-shot errand, and it must work +/// while the app is running — which is exactly when the uninstaller finds it. +func retractLoginRegistrationsAndExit() { + LoginItem.retractAll() + exit(0) +} + +/// Exits if another copy of this install already owns the session. /// /// The login item is a launchd agent now, and `register()` on a `RunAtLoad` job /// `exec`s the binary immediately — from the Settings toggle and from the @@ -60,37 +78,75 @@ func makeMainMenu() -> NSMenu { /// LaunchServices, so nothing else dedupes it. Without this the user gets two /// menubar items, and the duplicate carries `--background`, so under the default /// "Only at login" it opens no window and there is no way to tell which icon is -/// which. See `SingleInstance` and docs/adr/0014-login-item-launch-marker.md. +/// which. See `InstanceLock` and docs/adr/0014-login-item-launch-marker.md. /// -/// Only the loser exits — `SingleInstance.shouldYield` is a total order, so a -/// simultaneous pair cannot both stand down and leave the Mac with no app. -func yieldToRunningInstance() { - // No bundle identifier means a bare `swift run` binary, which LaunchServices - // does not track: nothing to compare against, and no agent to have spawned us. - guard let id = Bundle.main.bundleIdentifier else { return } - let mePID = ProcessInfo.processInfo.processIdentifier - let running = NSRunningApplication.runningApplications(withBundleIdentifier: id) - let others = running - .filter { $0.processIdentifier != mePID } - .map { InstanceIdentity(pid: $0.processIdentifier, launchedAt: $0.launchDate) } - guard !others.isEmpty else { return } - let own = InstanceIdentity( - pid: mePID, - launchedAt: NSRunningApplication.current.launchDate) - guard SingleInstance.shouldYield(own: own, others: others) else { return } - NSLog("DezhbanMenu: another instance is already running (pid \(others.map(\.pid))); exiting") - exit(0) +/// Returns the lock on success. The caller must keep it alive for the lifetime of +/// the process — the lock IS the open file descriptor. +func acquireSessionOwnership() -> InstanceLock? { + // No bundle identifier means a bare `swift run` binary: no agent could have + // spawned it, and nothing to scope a lock to. + guard let id = Bundle.main.bundleIdentifier, + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + else { return nil } + + let lock = InstanceLock.forBundle( + path: Bundle.main.bundleURL.path, identifier: id, cachesDirectory: caches) + switch lock.acquire() { + case .acquired: + return lock + case .unavailable(let why): + // Never refuse to start over this. A duplicate icon is a smaller failure + // than an app that will not launch because a cache directory is broken. + NSLog("DezhbanMenu: instance lock unavailable, starting anyway: \(why)") + return lock + case .heldByAnother: + // A background launch loses silently — that copy was never going to show + // the user anything. A launch the user performed must not be a no-op, so + // hand them over to the instance that owns the session: focus it, and ask + // it to open its window, which it may not currently have (the incumbent + // may be a --background login launch under the default "Only at login"). + if !LaunchVisibility.isBackgroundLaunch(arguments: CommandLine.arguments) { + let mePID = ProcessInfo.processInfo.processIdentifier + let incumbent = NSRunningApplication + .runningApplications(withBundleIdentifier: id) + .first { + $0.processIdentifier != mePID && !$0.isTerminated + && $0.bundleURL?.standardizedFileURL == Bundle.main.bundleURL.standardizedFileURL + } + incumbent?.activate() + // A notification rather than re-opening the bundle through + // NSWorkspace: asking LaunchServices to open the app we are in the + // middle of quitting could spawn yet another copy, which would find + // the lock held and ask again. + DistributedNotificationCenter.default().postNotificationName( + NSNotification.Name(AppDelegate.openWindowNotification), + object: id, userInfo: nil, deliverImmediately: true) + } + NSLog("DezhbanMenu: another copy of this install owns the session; exiting") + exit(0) + } } +// Both of these run before NSApplication exists: the errand mode never becomes +// an app at all, and a duplicate must exit before it can put a tile in the Dock. +if CommandLine.arguments.contains("--unregister-login-item") { + retractLoginRegistrationsAndExit() +} +// Held for the lifetime of the process — the lock is the open file descriptor, +// so letting this go out of scope would release the session to the next starter. +let sessionLock = acquireSessionOwnership() + // Regular app (not an LSUIElement agent): the Dock tile doubles as a state // display — AppDelegate swaps NSApp.applicationIconImage to match the // enforcement posture, and that needs a Dock icon to exist. The bundled // Info.plist sets LSUIElement=false for the same reason. let app = NSApplication.shared -// Before the delegate installs a menubar item or a timer: see above. -yieldToRunningInstance() let delegate = AppDelegate() app.delegate = delegate app.setActivationPolicy(.regular) app.mainMenu = makeMainMenu() app.run() + +// Referenced so the lock cannot be optimised away as unused; `app.run()` never +// returns, so this line is only ever reached conceptually. +_ = sessionLock diff --git a/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift b/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift new file mode 100644 index 0000000..00b8dfe --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import DezhbanCore + +struct InstanceLockTests { + private func tempDir() throws -> URL { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dezhban-instancelock-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// The case that put this code here: registering the login agent execs a + /// second copy while the first is serving the menubar. `flock` is per open + /// file description, so a second `acquire()` on the same path is refused even + /// from within one process — which is exactly what makes this testable. + @Test func aSecondHolderIsRefused() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let path = dir.appendingPathComponent("a.lock") + + let first = InstanceLock(url: path) + let second = InstanceLock(url: path) + defer { first.release(); second.release() } + + #expect(first.acquire() == .acquired) + #expect(second.acquire() == .heldByAnother) + } + + /// The failure the previous design could not rule out: two copies each + /// deciding the other one wins, leaving no app running at all. A lock cannot + /// express that — someone holds it. + @Test func exactlyOneOfManyContendersWins() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let path = dir.appendingPathComponent("b.lock") + + let contenders = (0 ..< 5).map { _ in InstanceLock(url: path) } + defer { contenders.forEach { $0.release() } } + + let winners = contenders.filter { $0.acquire() == .acquired } + #expect(winners.count == 1) + } + + /// Releasing hands the session to the next starter. This is what makes a + /// crashed or force-quit predecessor harmless: the kernel does this for us + /// when the process dies, which a pid file would not. + @Test func releasingLetsTheNextInstanceIn() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let path = dir.appendingPathComponent("c.lock") + + let first = InstanceLock(url: path) + let second = InstanceLock(url: path) + defer { second.release() } + + #expect(first.acquire() == .acquired) + #expect(second.acquire() == .heldByAnother) + first.release() + #expect(second.acquire() == .acquired) + } + + /// Re-acquiring is not a second holder — a caller that asks twice must not be + /// told it lost to itself. + @Test func reacquiringTheSameLockIsIdempotent() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let lock = InstanceLock(url: dir.appendingPathComponent("d.lock")) + defer { lock.release() } + + #expect(lock.acquire() == .acquired) + #expect(lock.acquire() == .acquired) + } + + /// An unwritable location must not stop the app from starting. A broken cache + /// directory is a worse thing to fail a launch on than a duplicate icon. + @Test func anUnopenableLockPathIsReportedRatherThanBlocking() { + let lock = InstanceLock(url: URL(fileURLWithPath: "/dev/null/nope/e.lock")) + defer { lock.release() } + guard case .unavailable = lock.acquire() else { + Issue.record("expected .unavailable for an unopenable path") + return + } + } + + /// Two installs of the same app are not duplicates of each other: the + /// documented GUI dev loop runs dist/Dezhban.app while /Applications holds a + /// released copy, and an identifier-scoped lock made the dev build exit on + /// launch and silently test the installed one. + @Test func differentInstallPathsGetDifferentLocks() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let installed = InstanceLock.forBundle( + path: "/Applications/Dezhban.app", identifier: "com.example.app", cachesDirectory: dir) + let built = InstanceLock.forBundle( + path: "/Users/x/dev/dezhban/dist/Dezhban.app", identifier: "com.example.app", + cachesDirectory: dir) + defer { installed.release(); built.release() } + + #expect(installed.url != built.url) + #expect(installed.acquire() == .acquired) + #expect(built.acquire() == .acquired) + } + + /// The same install must derive the same name in every process, so the hash + /// cannot be Swift's per-process-seeded one. Pinning a literal is the only + /// way this test can fail if someone swaps it for `hashValue`. + @Test func theLockNameIsStableAcrossProcesses() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let a = InstanceLock.forBundle( + path: "/Applications/Dezhban.app", identifier: "com.example.app", cachesDirectory: dir) + let b = InstanceLock.forBundle( + path: "/Applications/Dezhban.app", identifier: "com.example.app", cachesDirectory: dir) + #expect(a.url == b.url) + // FNV-1a of the empty string is its offset basis; a seeded hash would not + // reproduce it. + #expect(InstanceLock.fnv1a("") == 0xcbf2_9ce4_8422_2325) + #expect(InstanceLock.fnv1a("a") == InstanceLock.fnv1a("a")) + #expect(InstanceLock.fnv1a("a") != InstanceLock.fnv1a("b")) + } +} diff --git a/gui/macos/Tests/DezhbanCoreTests/SingleInstanceTests.swift b/gui/macos/Tests/DezhbanCoreTests/SingleInstanceTests.swift deleted file mode 100644 index ee2c9b7..0000000 --- a/gui/macos/Tests/DezhbanCoreTests/SingleInstanceTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import Testing -@testable import DezhbanCore - -struct SingleInstanceTests { - private func at(_ seconds: TimeInterval) -> Date { - Date(timeIntervalSince1970: 1_700_000_000 + seconds) - } - - /// The case that put this code here: registering the login agent execs a - /// second copy while the first is serving the menubar. The newcomer yields. - @Test func theNewcomerYieldsToTheAppAlreadyRunning() { - let running = InstanceIdentity(pid: 100, launchedAt: at(0)) - let spawned = InstanceIdentity(pid: 900, launchedAt: at(60)) - #expect(SingleInstance.shouldYield(own: spawned, others: [running])) - #expect(!SingleInstance.shouldYield(own: running, others: [spawned])) - } - - /// The failure this ordering exists to prevent: if the rule were merely - /// "does anyone else exist", two copies racing at login would both stand - /// down and the Mac would end up with no app at all. Exactly one survives. - @Test func exactlyOneSurvivesWhenEveryCopyAsksAtOnce() { - let same = at(0) - let copies = [ - InstanceIdentity(pid: 300, launchedAt: same), - InstanceIdentity(pid: 100, launchedAt: same), - InstanceIdentity(pid: 200, launchedAt: same), - ] - let survivors = copies.filter { own in - !SingleInstance.shouldYield(own: own, others: copies.filter { $0.pid != own.pid }) - } - #expect(survivors.count == 1) - #expect(survivors.first?.pid == 100) - } - - /// `NSRunningApplication.launchDate` is documented as optional. An instance - /// whose age is unknown must lose, so the copy already serving the menubar - /// keeps serving it rather than being displaced by a newcomer AppKit could - /// not date. - @Test func unknownAgeYields() { - let dated = InstanceIdentity(pid: 500, launchedAt: at(10)) - let undated = InstanceIdentity(pid: 100, launchedAt: nil) - #expect(SingleInstance.shouldYield(own: undated, others: [dated])) - #expect(!SingleInstance.shouldYield(own: dated, others: [undated])) - } - - /// Two undated copies still resolve — pid breaks the tie, so this cannot - /// degrade into both-exit either. - @Test func twoUndatedCopiesStillResolve() { - let a = InstanceIdentity(pid: 100, launchedAt: nil) - let b = InstanceIdentity(pid: 200, launchedAt: nil) - #expect(!SingleInstance.shouldYield(own: a, others: [b])) - #expect(SingleInstance.shouldYield(own: b, others: [a])) - } - - /// The ordinary case — one app, nothing to yield to. - @Test func aLoneInstanceNeverYields() { - let only = InstanceIdentity(pid: 100, launchedAt: at(0)) - #expect(!SingleInstance.shouldYield(own: only, others: [])) - } - - /// A caller that fails to exclude this process from `others` must not make - /// the app quit on every launch. - @Test func seeingItselfInTheListIsNotADuplicate() { - let me = InstanceIdentity(pid: 100, launchedAt: at(0)) - #expect(!SingleInstance.shouldYield(own: me, others: [me])) - } -} diff --git a/gui/macos/build-app.sh b/gui/macos/build-app.sh index 4791687..f908437 100755 --- a/gui/macos/build-app.sh +++ b/gui/macos/build-app.sh @@ -111,6 +111,17 @@ if [[ "$agent_marker" -ne 1 ]]; then echo "build-app.sh: LoginAgent.plist ProgramArguments no longer pass --background — a login launch would be indistinguishable from a user launch" >&2 exit 1 fi +# 3. The label must be spelled the same in the three places that must agree: +# the plist, LoginItem.plistName (what SMAppService.agent is given), and +# uninstall.sh (what retracts it). Renaming it consistently in the plist AND +# here would otherwise satisfy check 1 while SMAppService named a file that +# does not exist — reported only as the .notFound status nobody reads. +for consumer in "$HERE/Sources/DezhbanMenu/LoginItem.swift" "$REPO_ROOT/packaging/macos/uninstall.sh"; do + if ! grep -q "$AGENT_LABEL" "$consumer"; then + echo "build-app.sh: $consumer does not mention '$AGENT_LABEL' — the label, LoginItem.plistName and the uninstaller have drifted apart, and login-at-launch would fail silently" >&2 + exit 1 + fi +done # Documentation, rendered from the repo's own markdown into the bundle. Shipping # it means the help pane works with every byte of egress cut — which is exactly diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 5b9789a..d95495d 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -51,16 +51,29 @@ pkill -x DezhbanMenu >/dev/null 2>&1 || true # The login item is a per-user launchd agent (SMAppService.agent), NOT a # LaunchServices entry: deleting the bundle does not retract it. Left registered it # fails to load at every subsequent login and lingers in System Settings → General -# → Login Items as an orphan the user has to hunt down. Booting it out needs that -# user's own GUI session, so root can only reach the console user — other accounts -# get the one command they need, printed at the end. +# → Login Items as an orphan the user has to hunt down. +# +# Only SMAppService.unregister() actually retracts the registration, and only the +# app can call it — `launchctl bootout` unloads the job for THIS boot and leaves +# the record that recreates it at the next login, pointing at a plist inside the +# bundle we are about to delete. So the app is run one last time, as the console +# user inside their GUI session, purely to retract itself. bootout follows as a +# belt-and-braces unload of the job it just retracted. +# +# All of it needs the user's own launchd session, so root can only reach the +# console user; other accounts get the one command they need, printed at the end. CONSOLE_USER=$(stat -f %Su /dev/console 2>/dev/null || echo "") +CONSOLE_UID="" if [ -n "$CONSOLE_USER" ] && [ "$CONSOLE_USER" != "root" ]; then CONSOLE_UID=$(id -u "$CONSOLE_USER" 2>/dev/null || echo "") - if [ -n "$CONSOLE_UID" ]; then - echo "unregistering the login agent for $CONSOLE_USER ..." - launchctl bootout "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1 || true +fi +if [ -n "$CONSOLE_UID" ]; then + echo "unregistering the login agent for $CONSOLE_USER ..." + if [ -x "$APP/Contents/MacOS/DezhbanMenu" ]; then + launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ + "$APP/Contents/MacOS/DezhbanMenu" --unregister-login-item >/dev/null 2>&1 || true fi + launchctl bootout "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1 || true fi rm -rf "$APP" @@ -92,6 +105,6 @@ echo echo "dezhban uninstalled — rules removed, service unregistered, files deleted." echo echo "If any OTHER account on this Mac ran the app, its login agent is still" -echo "registered there — root cannot reach another user's launchd session. From" -echo "that account, once:" -echo " launchctl bootout gui/\$(id -u)/$LOGIN_AGENT" +echo "registered there — root cannot reach another user's launchd session. Nothing" +echo "will start Dezhban (the bundle is gone), but the entry lingers under System" +echo "Settings → General → Login Items until that user removes it there." From b9f674be695f846c00e8a9fefca5efb4d4313990 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Fri, 21 Aug 2026 22:33:25 +0330 Subject: [PATCH 04/36] fix(gui): retract what is registered, not only what is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review round, eight findings, all in the registration lifecycle again. .requiresApproval is a live registration — it starts the app the moment the user approves it — but every unregister was guarded on .enabled, so it could not be retracted by the Settings switch or by the uninstaller's errand. The bundle would have been deleted with the registration still on file: exactly the orphan the errand exists to remove. "Is there a registration" and "will this start the app" are now separate questions and the retractions ask the first one. The switch also dead-ended there. Outcome.isOn counted awaiting-approval as on while isEnabled counted it as off, and the binding branches on isEnabled — so the user's attempt to switch it back off re-registered instead, with no way out. The displayed value and the branched-on value are one value now. The migration marked itself done before register() could fail. On an upgrade where the legacy item went away and the agent would not register, that left nothing starting the app at login, no retry, and no word to the user. It now retries, made safe by a second flag: an explicit "off" outlives every retry, so a retry can only restore what was already on. macOS has a second way to start the app at login and it carries no marker. "Reopen windows when logging back in" relaunches through LaunchServices with no arguments, which SMAppService.mainApp was reconciled with and a launchd agent is not — both would start and race for the lock, and a resume copy that won opened the window at login under the default "Only at login". That is this branch's own defect, made intermittent rather than absent. NSApp.disableRelaunchOnLogin() is the API for saying the login item is the only such path. Also: the duplicate hand-off opened a window even under "Open minimized: Always", so a second launch became the one way to make a window appear; it now consults the preference. Its observer was registered after four other calls, and distributed notifications are never queued, so a hand-off arriving during that prologue was dropped — it is the first statement now, and scoped to the bundle path so a duplicate launch of one install cannot open another install's window. The lock moved out of ~/Library/Caches, which macOS may purge from under a held descriptor, leaving the next launch to lock a fresh inode and run a second copy undetectably. And Outcome.failed no longer interpolates a raw SMAppService.Status into text a user reads. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 +- docs/adr/0014-login-item-launch-marker.md | 65 +++++++++++-- docs/contribute/testing.md | 18 +++- .../Sources/DezhbanCore/InstanceLock.swift | 15 ++- .../Sources/DezhbanMenu/AppDelegate.swift | 26 ++++- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 97 ++++++++++++++++--- gui/macos/Sources/DezhbanMenu/main.swift | 34 ++++--- .../DezhbanCoreTests/InstanceLockTests.swift | 13 +-- 8 files changed, 226 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96bd483..ef93429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,13 @@ current as you land changes. subsequent login; the Login Items entry reads "Dezhban" now instead of a raw job label. And the login toggle says what actually happened — including when macOS is holding the registration for your approval, and when it refuses to - remove the old login item and only you can clear it in System Settings. + remove the old login item and only you can clear it in System Settings; either + state can still be switched back off, which an earlier build could not do. + + Dezhban also stops relying on macOS's "Reopen windows when logging back in" to + leave it alone: that path relaunches the app at login without the marker, so it + is now opted out of explicitly, leaving the login item as the only thing that + starts the app at login. ## [0.11.0] - 2026-08-21 diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index f43f589..12cb34f 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -121,13 +121,37 @@ with an argument, the pre-`SMAppService` pattern. `dist/Dezhban.app` run against an installed `/Applications/Dezhban.app` is the documented GUI dev loop and those two are not duplicates of each other. + The lock file lives under Application Support, not `~/Library/Caches`. `flock` + is per-inode and macOS may purge a caches directory under disk pressure; a + purged lock file while the incumbent holds its descriptor means the next launch + creates a fresh inode, locks *that*, and runs a second copy undetectably. + A launch the *user* performed must never become a silent no-op, so the copy - that loses the lock focuses the winner and posts a distributed notification - asking it to open its window — the incumbent may be a `--background` login - launch with no window to be handed over to. A notification rather than - re-opening the bundle through `NSWorkspace`: asking LaunchServices to open the - app we are in the middle of quitting could spawn yet another copy, which would - find the lock held and ask again. + that loses the lock focuses the winner and — when this launch would have opened + a window at all — posts a distributed notification asking it to open its own, + since the incumbent may be a `--background` login launch with none. Gated on + the preference, because "Open minimized: Always" has to mean always: otherwise + a second launch of the same app becomes the one way to make a window appear. + Scoped by posting the bundle **path** as the notification object, since the + name derives from the bundle id and two installs may legitimately run side by + side. And a notification rather than re-opening the bundle through + `NSWorkspace`: asking LaunchServices to open the app we are in the middle of + quitting could spawn yet another copy, which would find the lock held and ask + again. The observer is registered as the first statement of + `applicationDidFinishLaunching` — distributed notifications are delivered + immediately and never queued, so anything ahead of it is time in which the + hand-off is dropped. +- **macOS has a second way to start the app at login, and it carries no marker.** + "Reopen windows when logging back in" relaunches whatever was running at + logout, through LaunchServices, with no arguments. `SMAppService.mainApp` was + reconciled with that path because it went through LaunchServices too; a launchd + agent is not, so both would start at login and race for the instance lock, and + a resume copy that won made the window open at login under the default + `bootOnly` — this very defect, intermittent instead of absent. + `NSApp.disableRelaunchOnLogin()` is the API for "the login item is the only way + I start at login", and the app calls it at launch. `MainWindow`'s + `isRestorable = false` covers window restoration; this covers app relaunch, + which is a different mechanism. - **Uninstalling has to retract the registration, and only the app can.** A LaunchServices login item disappears with its bundle; a per-user launchd agent does not. `launchctl bootout` is not the answer either — it unloads the job for @@ -175,10 +199,31 @@ with an argument, the pre-`SMAppService` pattern. and could never reach `register()`. `LoginItem.toggle()` therefore returns an `Outcome` rather than a `Bool`, and the `legacyStuck` case tells the user the one thing that does work: remove "Dezhban" under System Settings → General → - Login Items. Once they do, the toggle registers a clean agent. The same - `Outcome` carries `awaitingApproval`, because `register()` reports "the user - must approve this in System Settings" as a *status* rather than an error, and a - switch that snaps back with no explanation is indistinguishable from a bug. + Login Items. Once they do, the toggle registers a clean agent. + + The same `Outcome` carries `awaitingApproval`, because `register()` reports + "the user must approve this in System Settings" as a *status* rather than an + error, and a switch that snaps back with no explanation is indistinguishable + from a bug. `.requiresApproval` is why "is there a registration" and "will this + start the app" have to be separate questions: it is a live registration that + starts the app the moment approval lands, so the unregisters are guarded on the + former. Guarding them on `.enabled` meant an awaiting-approval registration + could not be retracted by the Settings switch *or* by the uninstaller's errand + — the bundle would be deleted with the registration still on file, which is the + orphan the errand exists to remove. `isEnabled` reports that same question, so + the value the switch shows and the value `toggle()` branches on are one value; + when they were two, an awaiting-approval registration painted the switch ON + while `toggle()` still read "off", and the user's attempt to switch it off + re-registered instead. + + One more thing the persisted flag may not swallow: a migration that retracted + the legacy item and then *failed* to register the agent leaves nothing starting + the app at login. Marking that migrated would cost the user a setting they had + switched on, with no retry ever, so it is deliberately left unmarked and + retried on the next launch. What makes the retry safe rather than a return of + the every-launch re-registration bug is a second flag, set whenever the user + switches login-at-launch off themselves: an explicit "off" outlives every + retry, so a retry can only restore what was already on. - **The marker could be passed by something other than the agent**, making a user launch look like a login launch. The only consequence is a window that does not open, and the Dock icon and "Open Dezhban…" both open it diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index b9c1996..f9b2ea9 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -790,7 +790,23 @@ task gui:build && open dist/Dezhban.app again from Finder. The second copy must exit *and* the first must come forward with its window open — that is the distributed notification in `acquireSessionOwnership()`. Doing nothing at all here is a worse bug than - the duplicate icon this check's predecessor covers. + the duplicate icon this check's predecessor covers. Then set "Open + minimized" to **Always** and repeat: the first copy must come forward with + **no** window, because always means always. +- [ ] **"Reopen windows when logging back in" does not start a second, unmarked + copy.** Check that box in System Settings → Desktop & Dock, leave the app + running, log out and back in. Exactly one copy must be running and it must + have come from the login agent, not the resume: `ps -o args= -p "$(pgrep -x + DezhbanMenu)"` ends in `--background`, and under the default "Only at + login" there is no window. This is `NSApp.disableRelaunchOnLogin()`; without + it, LaunchServices relaunches the app with no arguments and races the agent + for the lock. +- [ ] **An awaiting-approval registration can still be switched off.** Turn the + login item off *in System Settings* (not in Dezhban), then switch Dezhban's + "Open this app at login" on: the status line must say macOS is holding it + for approval, and the switch must then turn **off** again on the next click + rather than re-registering. `launchctl print + gui/$UID/com.behnam-rk.dezhban.app.login` must fail afterwards. - [ ] **The dev build is not deduped against the installed one.** With `/Applications/Dezhban.app` running, `task gui:build && open dist/Dezhban.app`. Both must run — the lock is keyed on the bundle path diff --git a/gui/macos/Sources/DezhbanCore/InstanceLock.swift b/gui/macos/Sources/DezhbanCore/InstanceLock.swift index 2d6e3af..8098e7b 100644 --- a/gui/macos/Sources/DezhbanCore/InstanceLock.swift +++ b/gui/macos/Sources/DezhbanCore/InstanceLock.swift @@ -39,7 +39,7 @@ public final class InstanceLock { case heldByAnother /// The lock file could not be opened at all (unwritable directory, and so /// on). Treated as `acquired` by callers: refusing to start because a - /// cache directory is broken would be a worse bug than a duplicate icon. + /// support directory is broken would be a worse bug than a duplicate icon. case unavailable(String) } @@ -54,7 +54,14 @@ public final class InstanceLock { self.url = url } - /// The conventional location: one file per install, under the user's caches. + /// The conventional location: one file per install, under Application + /// Support. + /// + /// **Not** under `~/Library/Caches`, which is where this first lived. `flock` + /// is per-inode, and macOS is licensed to purge a caches directory under disk + /// pressure — if it removed the file while the incumbent held its descriptor, + /// the next launch's `open(O_CREAT)` would make a *new* inode, take the lock + /// on that, and run a second copy of the app with nothing able to detect it. /// /// `bundlePath` is hashed rather than embedded so the name cannot exceed a /// filename limit, and hashed with FNV-1a rather than `hashValue` because @@ -62,8 +69,8 @@ public final class InstanceLock { /// *same* name, which a randomly seeded hash would not give them. public static func forBundle(path bundlePath: String, identifier: String, - cachesDirectory: URL) -> InstanceLock { - let dir = cachesDirectory.appendingPathComponent(identifier, isDirectory: true) + supportDirectory: URL) -> InstanceLock { + let dir = supportDirectory.appendingPathComponent(identifier, isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let name = "instance-" + String(fnv1a(bundlePath), radix: 16) + ".lock" return InstanceLock(url: dir.appendingPathComponent(name)) diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 2ac69d1..3923f3b 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -40,6 +40,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { static let openWindowNotification = "com.behnam-rk.dezhban.app.openWindow" func applicationDidFinishLaunching(_: Notification) { + // FIRST. A duplicate copy of the app posts this as it exits and then dies; + // the notification is delivered immediately and never queued, so anything + // ahead of this line is time in which a user-initiated launch is silently + // dropped — the one outcome `acquireSessionOwnership` exists to prevent. + // Scoped to the bundle path, which is what the poster sends: the name + // comes from the bundle id, and two installs of the app may legitimately + // run side by side (see InstanceLock). + DistributedNotificationCenter.default().addObserver( + self, selector: #selector(openWindowRequested), + name: NSNotification.Name(Self.openWindowNotification), + object: Bundle.main.bundleURL.path) + // macOS has a second way to start this app at login, and it does not pass + // the launch marker: "Reopen windows when logging back in" relaunches + // whatever was running at logout, through LaunchServices, with no + // arguments. `SMAppService.mainApp` used to be reconciled with that path + // because it went through LaunchServices too; a launchd agent is not, so + // both would start at login and race for the instance lock — and if the + // resume copy won, the window opened at login under the default "Only at + // login", the exact defect this replaced, now intermittent instead of + // absent. This is the API for saying "the login item is the only way I + // start at login". MainWindow's isRestorable = false covers window + // restoration; this covers app relaunch, which is a different thing. + NSApp.disableRelaunchOnLogin() NotificationManager.requestAuthorizationIfNeeded() // Resolve the config path once, off the main thread, before any pane asks for // it — every later read is then a memoized lookup rather than a shell-out on @@ -83,9 +106,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if LaunchPreference.current.opensWindow(backgroundLaunch: backgroundLaunch) { MainWindow.shared.open() } - DistributedNotificationCenter.default().addObserver( - self, selector: #selector(openWindowRequested), - name: NSNotification.Name(Self.openWindowNotification), object: nil) AppState.shared.refreshServiceState() AppState.shared.checkForUpdates() AppState.shared.offerFirstRunIfNeeded() diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index b896c0a..dff1481 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -36,6 +36,15 @@ enum LoginItem { /// `FirstRun`: a fact about this app on this account, not daemon config. private static let migratedKey = "dezhban.loginItemMigratedToAgent" + /// Set when the user switches login-at-launch off themselves. + /// + /// The migration is allowed to retry when it retracted the legacy item but + /// could not register the agent — otherwise that upgrade silently ends with + /// *nothing* starting the app at login and no retry, ever. This flag is what + /// keeps that retry from becoming the bug it replaced: an explicit "off" must + /// outlive it, so a retry never re-registers what the user turned off. + private static let userDisabledKey = "dezhban.loginItemUserDisabled" + /// What a `toggle()` actually achieved, so the UI can say something true. /// /// A plain `Bool` could not: `register()` reports "the user has to approve @@ -90,15 +99,39 @@ enum LoginItem { /// The legacy LaunchServices registration every build before the agent used. private static var legacyEnabled: Bool { SMAppService.mainApp.status == .enabled } - /// Whether anything at all will start this app at login — the agent or a - /// legacy registration the migration could not retract. + /// Whether a registration exists at all, as opposed to one that will start + /// the app *right now*. + /// + /// The difference is `.requiresApproval`: `register()` leaves the service + /// there when the user has previously switched this app off in System + /// Settings, and it is a live registration that starts the app the moment + /// they approve it. Guarding the unregisters on `.enabled` alone meant an + /// awaiting-approval registration could not be retracted by the Settings + /// switch *or* by the uninstaller's errand — the bundle would be deleted with + /// the registration still on file, which is the orphan the errand exists to + /// remove. + private static func registered(_ target: SMAppService) -> Bool { + switch target.status { + case .notRegistered, .notFound: return false + default: return true + } + } + + /// Whether anything at all is set up to start this app at login — the agent + /// or a legacy registration the migration could not retract. /// /// Both, not just the agent: on the failed-migration path the legacy item is /// still live, so the app still starts at login, and it starts *without* /// `--background`, which is the very bug this PR fixes. Reporting only the /// agent would show "off" while startup kept happening, and leave the user /// no control that reaches the thing launching them. - static var isEnabled: Bool { agentEnabled || legacyEnabled } + /// + /// This is the value the switch displays *and* the value `toggle()` branches + /// on; they must be the same one. When they were not, an awaiting-approval + /// registration painted the switch ON while `toggle()` still saw "off", so + /// the user's next click re-registered instead of disabling and there was no + /// way to switch login-at-launch off at all. + static var isEnabled: Bool { registered(service) || registered(.mainApp) } /// Toggles login-at-launch and reports what actually happened. /// @@ -112,6 +145,7 @@ enum LoginItem { } private static func enable() -> Outcome { + UserDefaults.standard.set(false, forKey: userDisabledKey) do { try service.register() } catch { @@ -122,12 +156,13 @@ enum LoginItem { // is going to make the user approve it, and the switch snapping back with // no explanation is indistinguishable from a bug. if service.status == .requiresApproval { return .awaitingApproval } - return agentEnabled ? .enabled : .failed("macOS reported the login item as \(service.status)") + return agentEnabled ? .enabled : .failed(describe(service.status)) } private static func disable() -> Outcome { - if agentEnabled { unregister(service, what: "login agent") } - if legacyEnabled { unregister(.mainApp, what: "legacy login item") } + UserDefaults.standard.set(true, forKey: userDisabledKey) + if registered(service) { unregister(service, what: "login agent") } + if registered(.mainApp) { unregister(.mainApp, what: "legacy login item") } if legacyEnabled { // The stuck path. Reported rather than worked around: registering the // agent alongside it would mean two launches at login, one with the @@ -137,7 +172,7 @@ enum LoginItem { NSLog("DezhbanMenu: the legacy login item could not be retracted") return .legacyStuck } - return agentEnabled ? .failed("the login agent is still registered") : .disabled + return registered(service) ? .failed("the login agent is still registered") : .disabled } /// Retracts everything that could start this app at login, best effort. @@ -148,8 +183,8 @@ enum LoginItem { /// this boot and leaves the record that recreates it at the next login — and /// only the app can call it. static func retractAll() { - if agentEnabled { unregister(service, what: "login agent") } - if legacyEnabled { unregister(.mainApp, what: "legacy login item") } + if registered(service) { unregister(service, what: "login agent") } + if registered(.mainApp) { unregister(.mainApp, what: "legacy login item") } } /// Moves an install that registered `SMAppService.mainApp` (every build @@ -161,9 +196,18 @@ enum LoginItem { /// succeeded or not — see `migratedKey`. static func migrateFromMainAppRegistration() { guard !UserDefaults.standard.bool(forKey: migratedKey) else { return } - defer { UserDefaults.standard.set(true, forKey: migratedKey) } + // An explicit "off" outlives every retry below. Without this, a migration + // allowed to retry would re-register what the user had switched off — the + // bug the persisted flag was introduced to kill. + guard !UserDefaults.standard.bool(forKey: userDisabledKey) else { + UserDefaults.standard.set(true, forKey: migratedKey) + return + } - guard legacyEnabled else { return } + guard legacyEnabled else { + UserDefaults.standard.set(true, forKey: migratedKey) + return + } unregister(.mainApp, what: "legacy login item") // Checked after the attempt rather than trusting it not to throw: what @@ -177,13 +221,40 @@ enum LoginItem { NSLog("DezhbanMenu: the legacy login item could not be retracted; " + "leaving login-at-launch as it was. Remove \"Dezhban\" under " + "System Settings → General → Login Items to move onto the login agent.") + UserDefaults.standard.set(true, forKey: migratedKey) + return + } + if registered(service) { + UserDefaults.standard.set(true, forKey: migratedKey) return } - guard !agentEnabled else { return } do { try service.register() + UserDefaults.standard.set(true, forKey: migratedKey) } catch { - NSLog("DezhbanMenu: could not register the login agent: \(error)") + // Deliberately NOT marked migrated. The legacy item is gone by now, + // so leaving it here means *nothing* starts the app at login — and + // with the flag set that would never be retried, silently costing the + // user a setting they had switched on. The retry is safe because it + // is gated on `userDisabledKey` above: it can only ever restore what + // was already on, never override an explicit "off". + NSLog("DezhbanMenu: could not register the login agent, will retry on next launch: \(error)") + } + } + + /// Words, not a raw `SMAppService.Status`. It is an imported `NS_ENUM` with no + /// `CustomStringConvertible`, so interpolating it put + /// `SMAppService.Status(rawValue: 3)` in front of the user — in the very type + /// that exists so the UI can say something true. + private static func describe(_ status: SMAppService.Status) -> String { + switch status { + case .notRegistered: return "macOS did not keep the registration." + case .enabled: return "the login item is enabled." + case .requiresApproval: + return "macOS needs you to approve Dezhban in System Settings → General → Login Items." + case .notFound: + return "macOS could not find the login item inside the app bundle — reinstall Dezhban." + @unknown default: return "macOS reported an unrecognised login-item state." } } diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index 04b08d9..f806485 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -86,11 +86,12 @@ func acquireSessionOwnership() -> InstanceLock? { // No bundle identifier means a bare `swift run` binary: no agent could have // spawned it, and nothing to scope a lock to. guard let id = Bundle.main.bundleIdentifier, - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + let support = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let lock = InstanceLock.forBundle( - path: Bundle.main.bundleURL.path, identifier: id, cachesDirectory: caches) + path: Bundle.main.bundleURL.path, identifier: id, supportDirectory: support) switch lock.acquire() { case .acquired: return lock @@ -102,9 +103,7 @@ func acquireSessionOwnership() -> InstanceLock? { case .heldByAnother: // A background launch loses silently — that copy was never going to show // the user anything. A launch the user performed must not be a no-op, so - // hand them over to the instance that owns the session: focus it, and ask - // it to open its window, which it may not currently have (the incumbent - // may be a --background login launch under the default "Only at login"). + // hand them over to the instance that owns the session. if !LaunchVisibility.isBackgroundLaunch(arguments: CommandLine.arguments) { let mePID = ProcessInfo.processInfo.processIdentifier let incumbent = NSRunningApplication @@ -114,13 +113,28 @@ func acquireSessionOwnership() -> InstanceLock? { && $0.bundleURL?.standardizedFileURL == Bundle.main.bundleURL.standardizedFileURL } incumbent?.activate() + // Ask it to open its window — which it may not currently have, since + // the incumbent may be a --background login launch — but only when + // this launch would have opened one itself. "Open minimized: Always" + // means always: a second launch of the same app must not become the + // one way to make a window appear, or the setting means one thing on + // the first launch and the opposite on the second. + // // A notification rather than re-opening the bundle through // NSWorkspace: asking LaunchServices to open the app we are in the // middle of quitting could spawn yet another copy, which would find // the lock held and ask again. - DistributedNotificationCenter.default().postNotificationName( - NSNotification.Name(AppDelegate.openWindowNotification), - object: id, userInfo: nil, deliverImmediately: true) + // + // Scoped to this install by posting the bundle PATH as the object. + // The name derives from the bundle id, and the lock deliberately lets + // dist/Dezhban.app run beside an installed copy — an unscoped + // notification would have a duplicate launch of one install open the + // other install's window. + if LaunchPreference.current.opensWindow(backgroundLaunch: false) { + DistributedNotificationCenter.default().postNotificationName( + NSNotification.Name(AppDelegate.openWindowNotification), + object: Bundle.main.bundleURL.path, userInfo: nil, deliverImmediately: true) + } } NSLog("DezhbanMenu: another copy of this install owns the session; exiting") exit(0) @@ -146,7 +160,3 @@ app.delegate = delegate app.setActivationPolicy(.regular) app.mainMenu = makeMainMenu() app.run() - -// Referenced so the lock cannot be optimised away as unused; `app.run()` never -// returns, so this line is only ever reached conceptually. -_ = sessionLock diff --git a/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift b/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift index 00b8dfe..72b2671 100644 --- a/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift @@ -72,8 +72,9 @@ struct InstanceLockTests { #expect(lock.acquire() == .acquired) } - /// An unwritable location must not stop the app from starting. A broken cache - /// directory is a worse thing to fail a launch on than a duplicate icon. + /// An unwritable location must not stop the app from starting. A broken + /// support directory is a worse thing to fail a launch on than a duplicate + /// icon. @Test func anUnopenableLockPathIsReportedRatherThanBlocking() { let lock = InstanceLock(url: URL(fileURLWithPath: "/dev/null/nope/e.lock")) defer { lock.release() } @@ -91,10 +92,10 @@ struct InstanceLockTests { let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } let installed = InstanceLock.forBundle( - path: "/Applications/Dezhban.app", identifier: "com.example.app", cachesDirectory: dir) + path: "/Applications/Dezhban.app", identifier: "com.example.app", supportDirectory: dir) let built = InstanceLock.forBundle( path: "/Users/x/dev/dezhban/dist/Dezhban.app", identifier: "com.example.app", - cachesDirectory: dir) + supportDirectory: dir) defer { installed.release(); built.release() } #expect(installed.url != built.url) @@ -109,9 +110,9 @@ struct InstanceLockTests { let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } let a = InstanceLock.forBundle( - path: "/Applications/Dezhban.app", identifier: "com.example.app", cachesDirectory: dir) + path: "/Applications/Dezhban.app", identifier: "com.example.app", supportDirectory: dir) let b = InstanceLock.forBundle( - path: "/Applications/Dezhban.app", identifier: "com.example.app", cachesDirectory: dir) + path: "/Applications/Dezhban.app", identifier: "com.example.app", supportDirectory: dir) #expect(a.url == b.url) // FNV-1a of the empty string is its offset basis; a seeded hash would not // reproduce it. From d80606a66b22d84061d07c3d5cafa1c2a6b72d4d Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Fri, 21 Aug 2026 22:52:56 +0330 Subject: [PATCH 05/36] fix(gui): make the migration's promised retry actually reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth review round. The retry added last round was dead code: by the time register() is attempted the legacy item is already confirmed gone, so a retry launch asks "is there a legacy item to migrate?", reads no, marks itself migrated and returns without ever registering again. A user whose register() failed once was left with nothing starting the app at login, permanently — the exact outcome the unmarked flag was introduced to prevent. A flag recorded when the legacy item is confirmed retracted, before the register, is what tells "this account had a login item and the agent is not up yet" from "this account never had one". Two more places the .enabled/.requiresApproval distinction still leaked. disable() decided its outcome from legacyEnabled while isEnabled counts a registration, so a legacy item left at .requiresApproval reported "App will not open at login" and then came back on when the pane was reopened — the pane contradicting itself, which is what unifying those two values was supposed to end. And the toggle's setter threw away the value it was handed and re-derived from a live read, so a stale switch inverted the click: with the Settings window open, remove the login item in System Settings, come back, click the still-ON switch to turn it off, and it turned login-at-launch on. LoginItem.set(enabled:) takes the state the user asked for. The instance lock keyed on an unresolved path while the incumbent match resolved one, so a symlinked install derived two different locks and both copies ran. Both sides resolve now. The test for it is built on a real symlink because resolvingSymlinksInPath() returns the path unchanged when the leaf does not exist — the first version passed for the wrong reason. The uninstaller also left the per-user directory this branch introduced, in the very hunk whose point is not leaving orphans, and ran the retraction errand unbounded and silent before deleting the bundle. Both fixed. One finding is documented rather than fixed: unregistering the agent may have launchd terminate the app, since in a login-started session the app IS the job's process. Routing the agent through /usr/bin/open would avoid it but resolves the bundle by identifier and could launch a different copy than the one that registered — a silent wrong-bundle launch in place of a nuisance. It is an ADR risk with a manual check, to be reopened with measurements rather than guesses. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +- docs/adr/0014-login-item-launch-marker.md | 24 +++++ docs/contribute/testing.md | 13 +++ .../Sources/DezhbanCore/InstanceLock.swift | 11 ++- .../Sources/DezhbanMenu/AppDelegate.swift | 2 +- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 90 ++++++++++++------- .../Sources/DezhbanMenu/SettingsView.swift | 8 +- gui/macos/Sources/DezhbanMenu/main.swift | 6 +- .../DezhbanCoreTests/InstanceLockTests.swift | 33 +++++++ packaging/macos/uninstall.sh | 16 +++- 10 files changed, 169 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef93429..c1b70ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,10 @@ current as you land changes. Dezhban also stops relying on macOS's "Reopen windows when logging back in" to leave it alone: that path relaunches the app at login without the marker, so it is now opted out of explicitly, leaving the login item as the only thing that - starts the app at login. + starts the app at login. And clicking the login switch acts on the state you + clicked, not on a re-read a moment later — with the Settings window open, + removing the login item in System Settings and coming back used to make the + next click turn login-at-launch *on*. ## [0.11.0] - 2026-08-21 diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 12cb34f..078a760 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -224,6 +224,30 @@ with an argument, the pre-`SMAppService` pattern. the every-launch re-registration bug is a second flag, set whenever the user switches login-at-launch off themselves: an explicit "off" outlives every retry, so a retry can only restore what was already on. + + That retry needs a third flag to exist at all, which is not obvious and was + got wrong first: by the time `register()` is reached the legacy item is already + confirmed gone, so a retry launch that asks "is there a legacy item to + migrate?" reads *no*, marks itself migrated and returns — never reaching + `register()` again. The promised retry was dead code. A flag recorded at the + moment the legacy item is confirmed retracted, before the register is + attempted, is what distinguishes "this account had a login item and the agent + is not up yet" from "this account never had one". +- **Switching login-at-launch off may terminate the app.** Unverified, and + listed here rather than worked around because the workarounds are worse than + the symptom. `SMAppService.unregister()` unloads the job from the launchd + domain, and launchd terminates a loaded job's running process — which, in a + session the agent started, is the app itself. So switching the Settings toggle + off from a login-started session may quit the app before it can show the + result. It is a nuisance rather than a risk: the daemon is what enforces, the + GUI is a status and control surface, and relaunching restores it. Routing the + agent through `/usr/bin/open` instead would sidestep it and hand the dedupe + back to LaunchServices, but it resolves the bundle by identifier and could + launch a *different* copy of the app than the one that registered — trading a + known nuisance for a silent wrong-bundle launch. There is a manual check for + this in [docs/contribute/testing.md](../contribute/testing.md); if it + reproduces, that trade is worth reopening with measurements rather than + guesses. - **The marker could be passed by something other than the agent**, making a user launch look like a login launch. The only consequence is a window that does not open, and the Dock icon and "Open Dezhban…" both open it diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index f9b2ea9..9573a10 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -801,6 +801,19 @@ task gui:build && open dist/Dezhban.app login" there is no window. This is `NSApp.disableRelaunchOnLogin()`; without it, LaunchServices relaunches the app with no arguments and races the agent for the lock. +- [ ] **Switching login-at-launch off from a login-started session.** Log out + and back in so the agent starts the app, then switch Settings → "Open this + app at login" **off**. `SMAppService.unregister()` unloads the launchd job + and launchd terminates a loaded job's process — which here is the app — so + watch for the app quitting instead of showing the status line. Recorded as + an open risk in [ADR-0014](../adr/0014-login-item-launch-marker.md); if it + reproduces, note whether the registration was still retracted. +- [ ] **The migration retries a failed agent registration.** Hard to provoke + honestly: with a pre-agent install and login-at-launch on, make + `register()` fail once (an unsigned bundle is the easiest way), launch, and + confirm the log says it will retry. Then fix the bundle and launch again — + the agent must register. `defaults read com.behnam-rk.dezhban.app + dezhban.loginItemMigratedToAgent` must be absent or 0 between the two. - [ ] **An awaiting-approval registration can still be switched off.** Turn the login item off *in System Settings* (not in Dezhban), then switch Dezhban's "Open this app at login" on: the status line must say macOS is holding it diff --git a/gui/macos/Sources/DezhbanCore/InstanceLock.swift b/gui/macos/Sources/DezhbanCore/InstanceLock.swift index 8098e7b..21f6160 100644 --- a/gui/macos/Sources/DezhbanCore/InstanceLock.swift +++ b/gui/macos/Sources/DezhbanCore/InstanceLock.swift @@ -67,12 +67,21 @@ public final class InstanceLock { /// filename limit, and hashed with FNV-1a rather than `hashValue` because /// Swift's is seeded per process — two copies of the app must derive the /// *same* name, which a randomly seeded hash would not give them. + /// Symlinks are resolved before hashing, and must be: two launches of the same + /// install whose `bundleURL` spells differently (`/tmp` against + /// `/private/tmp`, a symlinked install directory) would otherwise derive + /// *different* lock files, both acquire, and both run — the failure this class + /// exists to prevent, arrived at silently. `standardizedFileURL` alone is not + /// enough: it collapses `.` and `..` and expands `~`, and does not touch + /// symlinks. The incumbent match in `acquireSessionOwnership` resolves the + /// same way, for the same reason. public static func forBundle(path bundlePath: String, identifier: String, supportDirectory: URL) -> InstanceLock { let dir = supportDirectory.appendingPathComponent(identifier, isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let name = "instance-" + String(fnv1a(bundlePath), radix: 16) + ".lock" + let key = URL(fileURLWithPath: bundlePath).resolvingSymlinksInPath().standardizedFileURL.path + let name = "instance-" + String(fnv1a(key), radix: 16) + ".lock" return InstanceLock(url: dir.appendingPathComponent(name)) } diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 3923f3b..1abf4bb 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -50,7 +50,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { DistributedNotificationCenter.default().addObserver( self, selector: #selector(openWindowRequested), name: NSNotification.Name(Self.openWindowNotification), - object: Bundle.main.bundleURL.path) + object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path) // macOS has a second way to start this app at login, and it does not pass // the launch marker: "Reopen windows when logging back in" relaunches // whatever was running at logout, through LaunchServices, with no diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index dff1481..b9f6499 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -45,6 +45,17 @@ enum LoginItem { /// outlive it, so a retry never re-registers what the user turned off. private static let userDisabledKey = "dezhban.loginItemUserDisabled" + /// Set the moment the legacy item is confirmed gone, before the agent is + /// registered. + /// + /// Without it the retry the comment on `migratedKey` promises is dead code: + /// on the next launch the legacy item is already retracted, so a migration + /// that keys "is there anything to do" off `legacyEnabled` reads false, marks + /// itself migrated and returns — never reaching `register()` again. The user + /// is then left with nothing starting the app at login, permanently, which is + /// the exact outcome the unmarked flag exists to prevent. + private static let legacyRetractedKey = "dezhban.loginItemLegacyRetracted" + /// What a `toggle()` actually achieved, so the UI can say something true. /// /// A plain `Bool` could not: `register()` reports "the user has to approve @@ -96,9 +107,6 @@ enum LoginItem { private static var agentEnabled: Bool { service.status == .enabled } - /// The legacy LaunchServices registration every build before the agent used. - private static var legacyEnabled: Bool { SMAppService.mainApp.status == .enabled } - /// Whether a registration exists at all, as opposed to one that will start /// the app *right now*. /// @@ -133,15 +141,20 @@ enum LoginItem { /// way to switch login-at-launch off at all. static var isEnabled: Bool { registered(service) || registered(.mainApp) } - /// Toggles login-at-launch and reports what actually happened. + /// Sets login-at-launch to `enabled` and reports what actually happened. + /// + /// Takes the state the user asked for rather than deriving it from a live + /// read, which inverted the click whenever the switch was stale: with the + /// Settings pane open, removing the login item in System Settings and then + /// clicking Dezhban's switch — visibly ON, so plainly an attempt to turn it + /// off — read "currently off" and turned login-at-launch on. /// /// Turning it OFF retracts both registrations, for the reason `isEnabled` /// reports both. Turning it ON registers only the agent — the legacy one is /// never created again. @discardableResult - static func toggle() -> Outcome { - if isEnabled { return disable() } - return enable() + static func set(enabled: Bool) -> Outcome { + enabled ? enable() : disable() } private static func enable() -> Outcome { @@ -163,7 +176,7 @@ enum LoginItem { UserDefaults.standard.set(true, forKey: userDisabledKey) if registered(service) { unregister(service, what: "login agent") } if registered(.mainApp) { unregister(.mainApp, what: "legacy login item") } - if legacyEnabled { + if registered(.mainApp) { // The stuck path. Reported rather than worked around: registering the // agent alongside it would mean two launches at login, one with the // marker and one without, and whichever won the race would decide @@ -200,48 +213,63 @@ enum LoginItem { // allowed to retry would re-register what the user had switched off — the // bug the persisted flag was introduced to kill. guard !UserDefaults.standard.bool(forKey: userDisabledKey) else { - UserDefaults.standard.set(true, forKey: migratedKey) + markMigrated() return } - guard legacyEnabled else { - UserDefaults.standard.set(true, forKey: migratedKey) + if registered(.mainApp) { + unregister(.mainApp, what: "legacy login item") + // Checked after the attempt rather than trusting it not to throw: what + // matters is whether the old item is actually gone. + if registered(.mainApp) { + // Same reasoning as `disable()`'s stuck path — the agent is left + // unregistered rather than stacked on top of a live legacy item. + // The Settings toggle reports the legacy registration, so the user + // can see login-at-launch is on; clearing it is a System Settings + // job, which `Outcome.legacyStuck` spells out when they try. + NSLog("DezhbanMenu: the legacy login item could not be retracted; " + + "leaving login-at-launch as it was. Remove \"Dezhban\" under " + + "System Settings → General → Login Items to move onto the login agent.") + markMigrated() + return + } + // Recorded BEFORE the register below, and this is the whole point of + // the flag: it is what a retry launch has to go on, since by then the + // legacy item is gone and there is nothing else left to tell "this + // account had a login item to migrate" from "this account never did". + UserDefaults.standard.set(true, forKey: legacyRetractedKey) + } else if !UserDefaults.standard.bool(forKey: legacyRetractedKey) { + // Nothing was ever registered the old way on this account, so there is + // nothing to move onto the agent. Turning login-at-launch on is the + // user's call, via Settings. + markMigrated() return } - unregister(.mainApp, what: "legacy login item") - // Checked after the attempt rather than trusting it not to throw: what - // matters is whether the old item is actually gone. - if legacyEnabled { - // Same reasoning as `disable()`'s stuck path — the agent is left - // unregistered rather than stacked on top of a live legacy item. The - // Settings toggle reports the legacy registration, so the user can - // see login-at-launch is on; clearing it is a System Settings job, - // which `Outcome.legacyStuck` spells out when they try. - NSLog("DezhbanMenu: the legacy login item could not be retracted; " - + "leaving login-at-launch as it was. Remove \"Dezhban\" under " - + "System Settings → General → Login Items to move onto the login agent.") - UserDefaults.standard.set(true, forKey: migratedKey) - return - } + // Reached with the legacy item confirmed gone — now, or on an earlier + // launch whose register() failed. if registered(service) { - UserDefaults.standard.set(true, forKey: migratedKey) + markMigrated() return } do { try service.register() - UserDefaults.standard.set(true, forKey: migratedKey) + markMigrated() } catch { // Deliberately NOT marked migrated. The legacy item is gone by now, // so leaving it here means *nothing* starts the app at login — and // with the flag set that would never be retried, silently costing the - // user a setting they had switched on. The retry is safe because it - // is gated on `userDisabledKey` above: it can only ever restore what - // was already on, never override an explicit "off". + // user a setting they had switched on. The retry is safe because it is + // gated on `userDisabledKey` above: it can only ever restore what was + // already on, never override an explicit "off". NSLog("DezhbanMenu: could not register the login agent, will retry on next launch: \(error)") } } + private static func markMigrated() { + UserDefaults.standard.set(true, forKey: migratedKey) + } + /// Words, not a raw `SMAppService.Status`. It is an imported `NS_ENUM` with no /// `CustomStringConvertible`, so interpolating it put /// `SMAppService.Status(rawValue: 3)` in front of the user — in the very type diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index c5c2b8b..499ac60 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -780,12 +780,16 @@ struct SettingsView: View { private var loginBinding: Binding { Binding( get: { loginEnabled }, - set: { _ in + set: { wanted in + // `wanted`, not a re-read of live state: the switch can be stale + // (the login item is also removable in System Settings), and + // deciding from a fresh read then inverted the click. + // // The outcome, not a bool: macOS can accept the registration and // still hold it for the user's approval, and there is one path // where only they can clear the old login item. A switch that // snaps back with no explanation reads as a bug. - let outcome = LoginItem.toggle() + let outcome = LoginItem.set(enabled: wanted) loginEnabled = outcome.isOn status = outcome.message }) diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index f806485..c43b660 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -110,7 +110,8 @@ func acquireSessionOwnership() -> InstanceLock? { .runningApplications(withBundleIdentifier: id) .first { $0.processIdentifier != mePID && !$0.isTerminated - && $0.bundleURL?.standardizedFileURL == Bundle.main.bundleURL.standardizedFileURL + && $0.bundleURL?.resolvingSymlinksInPath().standardizedFileURL + == Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL } incumbent?.activate() // Ask it to open its window — which it may not currently have, since @@ -133,7 +134,8 @@ func acquireSessionOwnership() -> InstanceLock? { if LaunchPreference.current.opensWindow(backgroundLaunch: false) { DistributedNotificationCenter.default().postNotificationName( NSNotification.Name(AppDelegate.openWindowNotification), - object: Bundle.main.bundleURL.path, userInfo: nil, deliverImmediately: true) + object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path, + userInfo: nil, deliverImmediately: true) } } NSLog("DezhbanMenu: another copy of this install owns the session; exiting") diff --git a/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift b/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift index 72b2671..c9027f3 100644 --- a/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift @@ -103,6 +103,39 @@ struct InstanceLockTests { #expect(built.acquire() == .acquired) } + /// Two spellings of one install are one install. A symlinked install + /// directory is the realistic case (`/tmp` is itself a symlink to + /// `/private/tmp` on macOS), and deriving two locks from it would let both + /// copies run — silently, which is the whole failure this class prevents. + /// + /// Built on a real symlink to a real bundle directory on purpose: + /// `resolvingSymlinksInPath()` returns the path unchanged when the leaf does + /// not exist, so a test written against two invented paths would pass or fail + /// for reasons that have nothing to do with the code. + @Test func equivalentPathsGetTheSameLock() throws { + let root = try tempDir() + defer { try? FileManager.default.removeItem(at: root) } + let locks = root.appendingPathComponent("locks", isDirectory: true) + try FileManager.default.createDirectory(at: locks, withIntermediateDirectories: true) + + let real = root.appendingPathComponent("real", isDirectory: true) + let bundle = real.appendingPathComponent("Dezhban.app", isDirectory: true) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + let link = root.appendingPathComponent("link", isDirectory: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) + + let viaReal = InstanceLock.forBundle( + path: bundle.path, identifier: "com.example.app", supportDirectory: locks) + let viaLink = InstanceLock.forBundle( + path: link.appendingPathComponent("Dezhban.app").path, + identifier: "com.example.app", supportDirectory: locks) + defer { viaReal.release(); viaLink.release() } + + #expect(viaReal.url == viaLink.url) + #expect(viaReal.acquire() == .acquired) + #expect(viaLink.acquire() == .heldByAnother) + } + /// The same install must derive the same name in every process, so the hash /// cannot be Swift's per-process-seeded one. Pinning a literal is the only /// way this test can fail if someone swaps it for `hashValue`. diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index d95495d..bf22516 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -21,6 +21,7 @@ STATE_DIR=/var/db/dezhban PLIST=/Library/LaunchDaemons/dezhban.plist SHARE_DIR=/usr/local/share/dezhban LOGIN_AGENT=com.behnam-rk.dezhban.app.login +APP_BUNDLE_ID=com.behnam-rk.dezhban.app if [ "$(id -u)" -ne 0 ]; then echo "error: run as root — sudo sh $0" >&2 @@ -70,10 +71,23 @@ fi if [ -n "$CONSOLE_UID" ]; then echo "unregistering the login agent for $CONSOLE_USER ..." if [ -x "$APP/Contents/MacOS/DezhbanMenu" ]; then + # Bounded. The errand talks to launchd over XPC, and this runs before + # `rm -rf "$APP"` — an uninstaller that hangs here, silently (output is + # discarded), leaves the machine mid-removal with no message on screen. launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ - "$APP/Contents/MacOS/DezhbanMenu" --unregister-login-item >/dev/null 2>&1 || true + "$APP/Contents/MacOS/DezhbanMenu" --unregister-login-item >/dev/null 2>&1 & + errand=$! + ( sleep 15; kill -9 "$errand" >/dev/null 2>&1 ) & + watchdog=$! + wait "$errand" >/dev/null 2>&1 || true + kill -9 "$watchdog" >/dev/null 2>&1 || true fi launchctl bootout "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1 || true + # The app's own per-user directory: nothing but the instance lock the GUI + # takes at startup to keep a second copy of itself from running. Machine- + # derived, none of it the user's — and a file this version creates that no + # earlier one did, so leaving it would make this script's own promise false. + rm -rf "/Users/$CONSOLE_USER/Library/Application Support/$APP_BUNDLE_ID" fi rm -rf "$APP" From 38b78c7fb6138f60760ef9e6a4afa5fecbc1191e Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Fri, 21 Aug 2026 23:13:56 +0330 Subject: [PATCH 06/36] fix(gui): the migration must not carry forward an off it was never told about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth review round, and the sharpest finding is the migration reading the wrong question. It gated on "is there a legacy registration", and .requiresApproval is exactly what mainApp reports once the user has switched Dezhban off under System Settings — so an upgrade treated a deliberate off as something to carry forward, retracted it, registered the agent, and turned login-at-launch back on behind their back. The userDisabledKey added last round cannot help: a pre-upgrade user never set it. The migration decides on .enabled now; the unregister guards keep asking about presence, because a registration awaiting approval is still one to retract. The migration also ran from any bundle. register() records the plist of the calling bundle while the flag is shared by every copy of the app, so an upgrader who tried the app zip from ~/Downloads before moving it — or a dev running dist/ — pointed the login agent at a bundle about to move and marked the account done forever, reported only as a status nobody reads. It runs only from /Applications now, and does not mark the account otherwise, so the installed copy still does the work. Two more places the switch could lie. enable() would register the agent beside a live legacy item, which disable() and the migration both refuse by name — the stuck path led straight to it: switch reads on, click off, click on, both registered, and the next login is the two-launch race this branch exists to remove. And a failed agent unregister came back as .failed, whose isOn is false, painting the switch OFF while the registration was live and the app kept starting at login; unregister swallows its throw, so that was reachable. It has its own outcome now. The hand-off race was real beyond the ordering fix. The lock is taken before NSApplication exists, so a request posted in the gap before the observer is installed reached nobody — landing exactly at login, when someone impatient double-clicks a slow-starting app. A HandoffRequest file beside the lock now backs the notification, consumed both at observer install and on the tick the app already runs. Requests carry freshness so one the incumbent never got is not inherited by the next app to start and turned into a window nobody asked for. Uninstaller: the per-user directory was removed via a hardcoded /Users/, which misses a network or relocated home and made the closing "files deleted" untrue for those accounts; it asks dscl now. And the errand watchdog leaked a sleep past the end of the script and could fire a kill -9 at a recycled pid, so completion is signalled by a file — an exited-but-unreaped child is a zombie that kill -0 still calls alive, so polling the pid would have waited out the full timeout on every successful run. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +- docs/adr/0014-login-item-launch-marker.md | 41 ++++++++++ docs/contribute/testing.md | 19 +++++ .../Sources/DezhbanCore/HandoffRequest.swift | 69 ++++++++++++++++ .../Sources/DezhbanMenu/AppDelegate.swift | 14 ++++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 76 ++++++++++++++++-- gui/macos/Sources/DezhbanMenu/main.swift | 22 ++++- .../HandoffRequestTests.swift | 80 +++++++++++++++++++ packaging/macos/uninstall.sh | 48 ++++++++--- 9 files changed, 355 insertions(+), 19 deletions(-) create mode 100644 gui/macos/Sources/DezhbanCore/HandoffRequest.swift create mode 100644 gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b70ae..8ceba3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,10 @@ current as you land changes. starts the app at login. And clicking the login switch acts on the state you clicked, not on a re-read a moment later — with the Settings window open, removing the login item in System Settings and coming back used to make the - next click turn login-at-launch *on*. + next click turn login-at-launch *on*. If you had switched Dezhban off under + System Settings → General → Login Items, upgrading leaves it off; and a copy of + the app run from somewhere other than `/Applications` no longer claims the + login item for a location it is about to be moved out of. ## [0.11.0] - 2026-08-21 diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 078a760..750fbe1 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -141,6 +141,18 @@ with an argument, the pre-`SMAppService` pattern. `applicationDidFinishLaunching` — distributed notifications are delivered immediately and never queued, so anything ahead of it is time in which the hand-off is dropped. + + That is not the whole window, though, and the rest of it needs a file. The lock + is taken before `NSApplication` exists, so between acquiring it and installing + the observer there is a stretch in which a hand-off is posted to nobody — short, + but landing exactly at login, when someone impatient with a slow start + double-clicks the app. So the losing copy writes a `HandoffRequest` beside the + lock as well as posting, and the incumbent consumes it both when it installs the + observer and on the ordinary once-a-second tick it already runs. Requests carry + their own freshness: one the incumbent never got to must not be inherited by the + *next* app to start and turned into a window nobody asked for, so a stale file is + discarded rather than obeyed, and a process that has just taken the lock discards + whatever it finds as belonging to a predecessor. - **macOS has a second way to start the app at login, and it carries no marker.** "Reopen windows when logging back in" relaunches whatever was running at logout, through LaunchServices, with no arguments. `SMAppService.mainApp` was @@ -233,6 +245,35 @@ with an argument, the pre-`SMAppService` pattern. moment the legacy item is confirmed retracted, before the register is attempted, is what distinguishes "this account had a login item and the agent is not up yet" from "this account never had one". + + The migration decides on `.enabled`, not on "is there a registration". These + are different questions and using one predicate for both was a bug in the + user's favour nowhere: `.requiresApproval` is what `mainApp` reports once the + user has switched Dezhban *off* under System Settings → General → Login Items, + so a migration gated on mere presence treated a deliberate off as something to + carry forward — retracting it and registering the agent, turning + login-at-launch back on during an upgrade, with `userDisabledKey` unable to + help because a pre-upgrade user never set it. The unregister *guards* keep + asking the presence question, because a `.requiresApproval` registration is + still a registration to retract. + + And the migration runs only from `/Applications`, without marking the account + migrated otherwise. `register()` records the plist of the *calling* bundle + (`BundleProgram` is bundle-relative) while the flag is shared by every copy of + the app, so one launch from `~/Downloads` — an upgrader trying the app zip + before moving it — or from `dist/` would point the login agent at a bundle + about to move or be deleted and mark the account done forever. It runs + unattended, so it takes the conservative branch; an explicit toggle from + Settings is the user's own call and is not gated. + + Two smaller versions of the same "the switch must not lie" rule. + `LoginItem.enable()` refuses to register the agent while a legacy item is live, + which `disable()` and the migration already refused — without it the stuck path + led straight to both being registered, which is the two-launch race. And a + failed *agent* unregister has its own outcome rather than reusing `.failed`, + whose `isOn` is false: `unregister()` swallows its throw, so that combination + painted the switch OFF while the registration was live and the app kept + starting at login. - **Switching login-at-launch off may terminate the app.** Unverified, and listed here rather than worked around because the workarounds are worse than the symptom. `SMAppService.unregister()` unloads the job from the launchd diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 9573a10..b218c2f 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -801,6 +801,25 @@ task gui:build && open dist/Dezhban.app login" there is no window. This is `NSApp.disableRelaunchOnLogin()`; without it, LaunchServices relaunches the app with no arguments and races the agent for the lock. +- [ ] **A hand-off that arrives before the app is observing still works.** The + race the `HandoffRequest` file exists for: log out and back in and + double-click the app in `/Applications` as early as you can, while it is + still starting from the login agent. The window must open — within a second + if the notification missed, since the file is picked up on the ordinary + tick. Then confirm no `.handoff` file is left in `~/Library/Application + Support/com.behnam-rk.dezhban.app/`. +- [ ] **A copy run from outside /Applications does not migrate the login item.** + Unzip `Dezhban-macos.app.zip` to `~/Downloads` on a Mac with a pre-agent + install and login-at-launch on, run it once, quit. The legacy login item + must still be there and `defaults read com.behnam-rk.dezhban.app + dezhban.loginItemMigratedToAgent` must be absent — otherwise the account is + marked done with the agent pointing into `~/Downloads`. Then run the copy in + `/Applications`: that one must migrate. +- [ ] **A login item the user turned off in System Settings stays off across an + upgrade.** With a pre-agent build, switch Dezhban off under System Settings + → General → Login Items (this leaves `mainApp` at `.requiresApproval`, not + unregistered), then upgrade and launch. Login-at-launch must still be off + and no agent registered. - [ ] **Switching login-at-launch off from a login-started session.** Log out and back in so the agent starts the app, then switch Settings → "Open this app at login" **off**. `SMAppService.unregister()` unloads the launchd job diff --git a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift new file mode 100644 index 0000000..72653ee --- /dev/null +++ b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift @@ -0,0 +1,69 @@ +import Foundation + +/// A file beside the instance lock saying "a user tried to launch this app; +/// please show yourself". +/// +/// The distributed notification that normally carries this is delivered +/// immediately and never queued, and the incumbent takes the instance lock +/// *before* `NSApplication` exists — so between acquiring the lock and installing +/// its observer there is a window in which a hand-off is posted to nobody. It is +/// short, but it lands exactly at login, when a user impatient with a slow start +/// double-clicks the app: the duplicate exits, the notification hits no observer, +/// and their launch does nothing visible at all, which is the one outcome the +/// hand-off exists to prevent. +/// +/// A file closes it because it waits. The incumbent consumes it when it installs +/// the observer *and* on its ordinary once-a-second tick, so a request written at +/// any point is picked up. The notification is kept as the fast path — this is the +/// one that cannot be missed. +public struct HandoffRequest { + /// How long a request stays meaningful. + /// + /// A request is a live "the user just did something", not a queued command. If + /// the incumbent died before consuming one, the *next* app to start must not + /// inherit it and pop a window nobody asked for, so an old file is discarded + /// rather than obeyed. Generous enough to cover a slow launch, short enough + /// that it cannot outlive the click that caused it. + public static let freshness: TimeInterval = 30 + + public let url: URL + + public init(url: URL) { + self.url = url + } + + /// Derived from the lock's own URL, so it is scoped per install for exactly + /// the reasons the lock is (see `InstanceLock.forBundle`). + public static func beside(lock: URL) -> HandoffRequest { + HandoffRequest(url: lock.deletingPathExtension().appendingPathExtension("handoff")) + } + + /// Records a request. Best effort: it is the notification's backstop, and a + /// failure to write it must never stop the losing process from exiting. + public func post() { + try? Data().write(to: url, options: .atomic) + } + + /// Removes any request without acting on it. + /// + /// Called by a process that has just *taken* the lock: anything on disk at + /// that moment predates its ownership and was meant for a predecessor. + public func discard() { + try? FileManager.default.removeItem(at: url) + } + + /// Whether a fresh request is waiting, removing it either way. + /// + /// Consuming even a stale one keeps a file that will never be obeyed from + /// sitting there being re-examined on every tick. + public func consume(now: Date = Date()) -> Bool { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + guard let attributes else { return false } + try? FileManager.default.removeItem(at: url) + guard let written = attributes[.modificationDate] as? Date else { return false } + let age = now.timeIntervalSince(written) + // A negative age means a clock change put the file in the future rather + // than that it is impossibly fresh; treat it the same as too old. + return age >= 0 && age <= Self.freshness + } +} diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 1abf4bb..84bcfac 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -51,6 +51,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { self, selector: #selector(openWindowRequested), name: NSNotification.Name(Self.openWindowNotification), object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path) + // And the file the notification cannot cover: a duplicate that posted + // while this process was still starting up found no observer, so its + // request is on disk. Checked here and again on every tick (see refresh). + consumeHandoffRequest() // macOS has a second way to start this app at login, and it does not pass // the launch marker: "Reopen windows when logging back in" relaunches // whatever was running at logout, through LaunchServices, with no @@ -124,6 +128,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { MainWindow.shared.open() } + /// The notification's backstop. A duplicate writes a file as well as posting, + /// because the post is never queued and this process may not have been + /// observing yet; this is polled from the same 1-second timer that already + /// reads the state file, so a request cannot go unseen however late it lands. + private func consumeHandoffRequest() { + guard let handoff = sessionHandoff, handoff.consume() else { return } + MainWindow.shared.open() + } + /// Clicking the Dock icon (re)opens the main window — the standard macOS /// contract for a regular app whose windows are all closed. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -143,6 +156,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func refresh() { pollStateFile() repaint() + consumeHandoffRequest() } /// Stats and decodes the state file on a background queue, publishing the diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index b9f6499..7c26f19 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -74,14 +74,23 @@ enum LoginItem { /// to retract it, so the app still starts at login without the launch /// marker. Only the user can clear this, in System Settings. case legacyStuck - /// Registration failed outright. + /// The **agent** registration survived an unregister that failed, so the + /// app still starts at login. + /// + /// Its own case, and `isOn == true`, because the alternative was the one + /// direction of lie that matters: `unregister()` swallows its throw, so + /// this used to come back as `.failed` — `isOn == false` — painting the + /// switch OFF while the registration was live and the app kept starting + /// at login. Exactly what `isEnabled`'s docstring says it exists to stop. + case agentStuck + /// Registration failed outright, and nothing is registered. case failed(String) /// Whether anything starts the app at login — what the Settings switch /// shows. var isOn: Bool { switch self { - case .enabled, .awaitingApproval, .legacyStuck: return true + case .enabled, .awaitingApproval, .legacyStuck, .agentStuck: return true case .disabled, .failed: return false } } @@ -95,8 +104,12 @@ enum LoginItem { return "macOS is holding this for your approval — enable Dezhban in " + "System Settings → General → Login Items." case .legacyStuck: - return "macOS would not remove the old login item. Remove \"Dezhban\" under " - + "System Settings → General → Login Items, then switch this on again." + return "macOS would not remove the old login item, so Dezhban will still open " + + "at login. Remove \"Dezhban\" under System Settings → General → Login " + + "Items, then switch this on again to use the new one." + case .agentStuck: + return "macOS would not remove the login item, so Dezhban will still open at " + + "login. Remove \"Dezhban\" under System Settings → General → Login Items." case .failed(let why): return "Could not change the login item: \(why)" } @@ -107,6 +120,19 @@ enum LoginItem { private static var agentEnabled: Bool { service.status == .enabled } + /// The legacy LaunchServices registration, *enabled* — as opposed to merely + /// present. + /// + /// The distinction decides whether the migration runs at all, and it is not + /// the same question the unregister guards ask. `.requiresApproval` is what + /// `mainApp` reports once the user has switched Dezhban off under System + /// Settings → General → Login Items — so a migration gated on "is there a + /// registration" would treat a deliberate *off* as something to carry + /// forward, retract it, and register the agent: login-at-launch back on + /// behind the user's back, on upgrade, with `userDisabledKey` unable to help + /// because a pre-upgrade user never set it. + private static var legacyEnabled: Bool { SMAppService.mainApp.status == .enabled } + /// Whether a registration exists at all, as opposed to one that will start /// the app *right now*. /// @@ -158,6 +184,16 @@ enum LoginItem { } private static func enable() -> Outcome { + // The agent must never be registered beside a live legacy item — that is + // two launches at login, one with the marker and one without, and + // whichever won the instance lock would decide whether the window opened. + // `disable()` and the migration both refuse it; this refused nothing, and + // the stuck-migration path led straight here: switch reads ON, user clicks + // it off, clicks it on again, and both are registered. + if registered(.mainApp) { + unregister(.mainApp, what: "legacy login item") + if registered(.mainApp) { return .legacyStuck } + } UserDefaults.standard.set(false, forKey: userDisabledKey) do { try service.register() @@ -185,7 +221,7 @@ enum LoginItem { NSLog("DezhbanMenu: the legacy login item could not be retracted") return .legacyStuck } - return registered(service) ? .failed("the login agent is still registered") : .disabled + return registered(service) ? .agentStuck : .disabled } /// Retracts everything that could start this app at login, best effort. @@ -209,6 +245,22 @@ enum LoginItem { /// succeeded or not — see `migratedKey`. static func migrateFromMainAppRegistration() { guard !UserDefaults.standard.bool(forKey: migratedKey) else { return } + // Only from a bundle that is going to stay put, and deliberately WITHOUT + // marking migrated — so the installed copy still does this later. + // + // `register()` records the plist of the *calling* bundle (`BundleProgram` + // is bundle-relative) while `migratedKey` is shared by every copy of the + // app, so one launch from ~/Downloads or from dist/ — an upgrader trying + // the app zip before moving it, or a dev build — would point the login + // agent at a bundle that is about to move or be deleted, and mark the + // account done forever. The symptom is an SMAppService status nobody + // reads. This runs unattended, so it has to be the conservative one; an + // explicit toggle from Settings is the user's own call and is not gated. + guard Bundle.main.bundleURL.deletingLastPathComponent().path == "/Applications" else { + NSLog("DezhbanMenu: not migrating the login item from a non-standard location " + + "(\(Bundle.main.bundleURL.path)); the copy in /Applications will do it") + return + } // An explicit "off" outlives every retry below. Without this, a migration // allowed to retry would re-register what the user had switched off — the // bug the persisted flag was introduced to kill. @@ -217,10 +269,12 @@ enum LoginItem { return } - if registered(.mainApp) { + if legacyEnabled { unregister(.mainApp, what: "legacy login item") // Checked after the attempt rather than trusting it not to throw: what - // matters is whether the old item is actually gone. + // matters is whether the old item is actually gone. `registered`, not + // `legacyEnabled`: a retraction that left it awaiting approval has not + // retracted anything. if registered(.mainApp) { // Same reasoning as `disable()`'s stuck path — the agent is left // unregistered rather than stacked on top of a live legacy item. @@ -238,6 +292,14 @@ enum LoginItem { // legacy item is gone and there is nothing else left to tell "this // account had a login item to migrate" from "this account never did". UserDefaults.standard.set(true, forKey: legacyRetractedKey) + } else if registered(.mainApp) { + // Present but not enabled — the user switched it off in System + // Settings. Their "off" is the answer: nothing is carried forward and + // the agent is not registered. The stale registration is left alone + // rather than retracted behind their back; `isEnabled` reports it, so + // the switch shows it and `disable()` can clear it on request. + markMigrated() + return } else if !UserDefaults.standard.bool(forKey: legacyRetractedKey) { // Nothing was ever registered the old way on this account, so there is // nothing to move onto the agent. Turning login-at-launch on is the diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index c43b660..e3b96dc 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -52,6 +52,14 @@ func makeMainMenu() -> NSMenu { return main } +/// The hand-off request beside this install's instance lock, once known. +/// +/// A global because both ends of the launch need it: `acquireSessionOwnership()` +/// writes it from a losing copy, and `AppDelegate` consumes it — including on the +/// ordinary 1-second tick, which is what makes a request that arrived before the +/// observer existed still get honoured. +var sessionHandoff: HandoffRequest? + /// Retracts every login registration and exits, without starting the app. /// /// The uninstaller needs this. A LaunchServices login item disappeared with its @@ -83,6 +91,9 @@ func retractLoginRegistrationsAndExit() { /// Returns the lock on success. The caller must keep it alive for the lifetime of /// the process — the lock IS the open file descriptor. func acquireSessionOwnership() -> InstanceLock? { + // Set for the winner, read by AppDelegate. A hand-off request that arrives + // before the observer exists lands here instead of nowhere. + defer { _ = sessionHandoff } // No bundle identifier means a bare `swift run` binary: no agent could have // spawned it, and nothing to scope a lock to. guard let id = Bundle.main.bundleIdentifier, @@ -92,13 +103,17 @@ func acquireSessionOwnership() -> InstanceLock? { let lock = InstanceLock.forBundle( path: Bundle.main.bundleURL.path, identifier: id, supportDirectory: support) + sessionHandoff = HandoffRequest.beside(lock: lock.url) switch lock.acquire() { case .acquired: + // Anything already on disk was meant for a predecessor, not for us. + sessionHandoff?.discard() return lock case .unavailable(let why): // Never refuse to start over this. A duplicate icon is a smaller failure - // than an app that will not launch because a cache directory is broken. + // than an app that will not launch because a support directory is broken. NSLog("DezhbanMenu: instance lock unavailable, starting anyway: \(why)") + sessionHandoff?.discard() return lock case .heldByAnother: // A background launch loses silently — that copy was never going to show @@ -132,6 +147,11 @@ func acquireSessionOwnership() -> InstanceLock? { // notification would have a duplicate launch of one install open the // other install's window. if LaunchPreference.current.opensWindow(backgroundLaunch: false) { + // The file first, then the notification. The notification is the + // fast path but is never queued, and the incumbent may still be + // starting up with no observer installed — the file is the one + // that waits, and its ordinary once-a-second tick finds it. + sessionHandoff?.post() DistributedNotificationCenter.default().postNotificationName( NSNotification.Name(AppDelegate.openWindowNotification), object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path, diff --git a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift new file mode 100644 index 0000000..cb66561 --- /dev/null +++ b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import DezhbanCore + +struct HandoffRequestTests { + private func tempDir() throws -> URL { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dezhban-handoff-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// The whole point: a request written while nobody was observing is still + /// there to be found. + @Test func aPostedRequestIsConsumedOnce() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let request = HandoffRequest(url: dir.appendingPathComponent("a.handoff")) + + request.post() + #expect(request.consume()) + #expect(!request.consume()) + } + + /// Nothing waiting means nothing to do — this is asked once a second, so it + /// must be quiet. + @Test func noRequestIsNotARequest() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + #expect(!HandoffRequest(url: dir.appendingPathComponent("b.handoff")).consume()) + } + + /// A request outlives its click only briefly. If the incumbent died before + /// consuming one, the next app to start must not inherit it and open a window + /// nobody asked for. + @Test func aStaleRequestIsDiscardedNotObeyed() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let request = HandoffRequest(url: dir.appendingPathComponent("c.handoff")) + + request.post() + let later = Date().addingTimeInterval(HandoffRequest.freshness + 5) + #expect(!request.consume(now: later)) + // Consumed anyway, so it is not re-examined on every tick forever. + #expect(!FileManager.default.fileExists(atPath: request.url.path)) + } + + /// A clock that moved backwards must not turn an old request into an + /// impossibly fresh one. + @Test func aRequestFromTheFutureIsNotFresh() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let request = HandoffRequest(url: dir.appendingPathComponent("d.handoff")) + + request.post() + let earlier = Date().addingTimeInterval(-3600) + #expect(!request.consume(now: earlier)) + } + + /// `discard()` is what a process that has just taken the lock calls: whatever + /// is on disk was meant for its predecessor. + @Test func discardRemovesWithoutReporting() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let request = HandoffRequest(url: dir.appendingPathComponent("e.handoff")) + + request.post() + request.discard() + #expect(!request.consume()) + } + + /// Scoped per install, like the lock it sits beside — two installs may + /// legitimately run side by side. + @Test func theRequestSitsBesideItsOwnLock() { + let one = HandoffRequest.beside(lock: URL(fileURLWithPath: "/x/instance-aaa.lock")) + let two = HandoffRequest.beside(lock: URL(fileURLWithPath: "/x/instance-bbb.lock")) + #expect(one.url != two.url) + #expect(one.url.path == "/x/instance-aaa.handoff") + } +} diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index bf22516..882a9b9 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -74,20 +74,48 @@ if [ -n "$CONSOLE_UID" ]; then # Bounded. The errand talks to launchd over XPC, and this runs before # `rm -rf "$APP"` — an uninstaller that hangs here, silently (output is # discarded), leaves the machine mid-removal with no message on screen. - launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ - "$APP/Contents/MacOS/DezhbanMenu" --unregister-login-item >/dev/null 2>&1 & + # + # Completion is signalled by a file rather than by watching the child: + # an exited-but-unreaped child is a zombie, which `kill -0` still reports + # as alive, so polling the pid would wait out the whole timeout on a + # perfectly successful run. And no `( sleep N; kill ) &` watchdog — that + # leaks its `sleep` past the end of the script and, if it ever fires late, + # aims a `kill -9` at a pid the system may have recycled. + errand_done="${TMPDIR:-/tmp}/dezhban-uninstall-errand.$$" + rm -f "$errand_done" + ( + launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ + "$APP/Contents/MacOS/DezhbanMenu" --unregister-login-item >/dev/null 2>&1 + : >"$errand_done" + ) & errand=$! - ( sleep 15; kill -9 "$errand" >/dev/null 2>&1 ) & - watchdog=$! + waited=0 + while [ ! -f "$errand_done" ] && [ "$waited" -lt 150 ]; do + sleep 0.1 + waited=$((waited + 1)) + done + if [ ! -f "$errand_done" ]; then + echo "note: retracting the login item did not finish in 15s; continuing" >&2 + kill -9 "$errand" >/dev/null 2>&1 || true + fi + rm -f "$errand_done" wait "$errand" >/dev/null 2>&1 || true - kill -9 "$watchdog" >/dev/null 2>&1 || true fi launchctl bootout "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1 || true - # The app's own per-user directory: nothing but the instance lock the GUI - # takes at startup to keep a second copy of itself from running. Machine- - # derived, none of it the user's — and a file this version creates that no - # earlier one did, so leaving it would make this script's own promise false. - rm -rf "/Users/$CONSOLE_USER/Library/Application Support/$APP_BUNDLE_ID" + # The app's own per-user directory: the instance lock the GUI takes at startup + # to keep a second copy of itself from running, and the hand-off file beside + # it. Machine-derived, none of it the user's — and files this version creates + # that no earlier one did, so leaving them would make this script's own + # promise false. + # + # The home directory is asked for, not assumed: a network or mobile account, + # or a relocated home, is not under /Users, and hardcoding that path made the + # closing "files deleted" line untrue for exactly those users. + CONSOLE_HOME=$(dscl . -read "/Users/$CONSOLE_USER" NFSHomeDirectory 2>/dev/null | + sed -n 's/^NFSHomeDirectory: //p') + if [ -n "$CONSOLE_HOME" ] && [ -d "$CONSOLE_HOME" ]; then + rm -rf "$CONSOLE_HOME/Library/Application Support/$APP_BUNDLE_ID" + fi fi rm -rf "$APP" From 51c1e0e2755ff9803131ea734716714c1ba8067f Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Fri, 21 Aug 2026 23:33:17 +0330 Subject: [PATCH 07/36] fix(gui): answer the hand-off once, and off the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth review round found no high-severity defect — every finding is in the seams around the mechanism, which is the point at which this is worth stopping. The hand-off answered itself twice. The file is written before the notification is posted, so in the normal case the notification handler opened the window and left the file on disk for the backstop to find a second later — and MainWindow.open() activates the app, so that is a second focus steal, or a window reopening just after the user closed it. The notification path discards the file it has just answered. The backstop also ran forever, putting a synchronous stat on the main thread on every tick: the exact hazard pollStateFile was restructured to remove, taken on for a cosmetic feature, and worst on the network and mobile homes uninstall.sh had just been taught to accommodate. The window it covers is a launch-time one — the lock is taken before NSApplication exists — so it now runs for a few seconds after launch and stops, and does its filesystem work off the main thread while it lasts. disable() wrote the user-disabled flag and then called an unregister that this ADR already records may get the process killed by launchd. UserDefaults does not write through synchronously, so losing that write while a migration retry was pending would let the next launch register the agent again — precisely what the flag exists to prevent. Flushed before the unregister. The stable-install check compared a literal string while every other path comparison in this branch resolves symlinks for stated reasons, so an install reached through a symlinked directory never migrated, ever, with a log line as the only symptom. It resolves now, and accepts ~/Applications as well as /Applications — the system-sanctioned per-user equivalent. The uninstaller's timeout killed only the wrapping subshell, leaving the DezhbanMenu it had started alive for `rm -rf "$APP"` to delete the bundle out from under — the thing the pkill above it exists to avoid, reintroduced on the one path the timeout is for. And the ADR shipped in this PR still described a toggle() that derives its direction from isEnabled, which is the control flow this branch replaced with set(enabled:) two commits ago. A new ADR that documents different control flow than the code is the failure the doc rules exist to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 29 ++++++---- docs/contribute/testing.md | 8 +-- .../Sources/DezhbanMenu/AppDelegate.swift | 50 +++++++++++++---- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 53 ++++++++++++++++--- gui/macos/Sources/DezhbanMenu/main.swift | 3 -- packaging/macos/uninstall.sh | 5 ++ 6 files changed, 115 insertions(+), 33 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 750fbe1..8b5e156 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -148,7 +148,13 @@ with an argument, the pre-`SMAppService` pattern. but landing exactly at login, when someone impatient with a slow start double-clicks the app. So the losing copy writes a `HandoffRequest` beside the lock as well as posting, and the incumbent consumes it both when it installs the - observer and on the ordinary once-a-second tick it already runs. Requests carry + observer and for a few seconds after — bounded rather than on every tick, + because a permanent per-tick stat on the main thread is the hazard + `pollStateFile` was restructured to remove and this feature is cosmetic; the + window it covers is a launch-time one, and once the observer exists the + notification carries every later hand-off. The notification path discards the + file as it handles it, since the file is written first and would otherwise have + the backstop open the window a second time. Requests carry their own freshness: one the incumbent never got to must not be inherited by the *next* app to start and turned into a window nobody asked for, so a stale file is discarded rather than obeyed, and a process that has just taken the lock discards @@ -206,10 +212,12 @@ with an argument, the pre-`SMAppService` pattern. If macOS keeps refusing to retract it, the app has no way out on its own, and it must not pretend otherwise: "toggle it off and on again" was the first - advice here and it was unreachable, because `toggle()` branches on `isEnabled`, - which the stuck legacy item holds true — so every attempt took the *off* branch - and could never reach `register()`. `LoginItem.toggle()` therefore returns an - `Outcome` rather than a `Bool`, and the `legacyStuck` case tells the user the + advice here and it was unreachable. The control was a `toggle()` that derived + the direction to move in from `isEnabled`, which the stuck legacy item holds + true — so every attempt took the *off* branch and could never reach + `register()`. The control is `LoginItem.set(enabled:)` now, taking the state the + user asked for, and it returns an `Outcome` rather than a `Bool`; the + `legacyStuck` case tells the user the one thing that does work: remove "Dezhban" under System Settings → General → Login Items. Once they do, the toggle registers a clean agent. @@ -223,10 +231,13 @@ with an argument, the pre-`SMAppService` pattern. could not be retracted by the Settings switch *or* by the uninstaller's errand — the bundle would be deleted with the registration still on file, which is the orphan the errand exists to remove. `isEnabled` reports that same question, so - the value the switch shows and the value `toggle()` branches on are one value; - when they were two, an awaiting-approval registration painted the switch ON - while `toggle()` still read "off", and the user's attempt to switch it off - re-registered instead. + what the switch shows agrees with what `set(enabled:)` does; when they + disagreed, an awaiting-approval registration painted the switch ON while + `isEnabled` read "off", and the user's attempt to switch it off re-registered + instead. Taking the requested state rather than re-deriving it also fixed the + stale-switch inversion: the login item is removable in System Settings too, so + a switch left showing ON while that happened turned login-at-launch *on* when + clicked. One more thing the persisted flag may not swallow: a migration that retracted the legacy item and then *failed* to register the agent leaves nothing starting diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index b218c2f..b3a8efb 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -804,9 +804,11 @@ task gui:build && open dist/Dezhban.app - [ ] **A hand-off that arrives before the app is observing still works.** The race the `HandoffRequest` file exists for: log out and back in and double-click the app in `/Applications` as early as you can, while it is - still starting from the login agent. The window must open — within a second - if the notification missed, since the file is picked up on the ordinary - tick. Then confirm no `.handoff` file is left in `~/Library/Application + still starting from the login agent. The window must open — within about + half a second if the notification missed it, from the bounded backstop that + runs for the first few seconds. It must open **once**: no second activation + a moment later, and a window you close right after must stay closed. Then + confirm no `.handoff` file is left in `~/Library/Application Support/com.behnam-rk.dezhban.app/`. - [ ] **A copy run from outside /Applications does not migrate the login item.** Unzip `Dezhban-macos.app.zip` to `~/Downloads` on a Mac with a pre-agent diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 84bcfac..f6fc95c 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -20,6 +20,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private let menu = NSMenu() private var timer: Timer? private var updateTimer: Timer? + /// Runs only for a few seconds after launch — see `startHandoffBackstop`. + private var handoffTimer: Timer? private var snapshot: Snapshot? private var lastMtime: Date? private var lastIconKey: String? @@ -53,8 +55,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path) // And the file the notification cannot cover: a duplicate that posted // while this process was still starting up found no observer, so its - // request is on disk. Checked here and again on every tick (see refresh). - consumeHandoffRequest() + // request is on disk. Checked now and for a few seconds more — see + // startHandoffBackstop for why it is bounded rather than on every tick. + startHandoffBackstop() // macOS has a second way to start this app at login, and it does not pass // the launch marker: "Reopen windows when logging back in" relaunches // whatever was running at logout, through LaunchServices, with no @@ -125,16 +128,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// already owning the session. Opening the window is the whole reason it /// bothered to tell us — it is standing in for the launch the user performed. @objc private func openWindowRequested() { + // The file is written BEFORE the notification is posted, so in the normal + // hand-off it is already on disk and this notification has answered it. + // Leaving it would have the backstop below open the window a second time + // up to a second later — and `MainWindow.open()` activates the app, so + // that is a second focus steal, or a window reopening after the user just + // closed it. + sessionHandoff?.discard() MainWindow.shared.open() } - /// The notification's backstop. A duplicate writes a file as well as posting, - /// because the post is never queued and this process may not have been - /// observing yet; this is polled from the same 1-second timer that already - /// reads the state file, so a request cannot go unseen however late it lands. - private func consumeHandoffRequest() { - guard let handoff = sessionHandoff, handoff.consume() else { return } - MainWindow.shared.open() + /// The notification's backstop, for the gap before the observer above exists. + /// + /// Bounded on purpose. The window it covers is a launch-time one — the lock is + /// taken before `NSApplication` — and once the observer is installed the + /// notification carries every later hand-off. Polling the file forever would + /// put a synchronous stat on the main thread on every tick, which is the exact + /// hazard `pollStateFile` below was restructured to remove, for a feature that + /// is cosmetic. So it runs for a few seconds after launch and then stops, and + /// even then it does its filesystem work off the main thread. + private func startHandoffBackstop() { + checkHandoffRequest() + var remaining = 10 + handoffTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in + remaining -= 1 + if remaining <= 0 { + timer.invalidate() + self?.handoffTimer = nil + } + self?.checkHandoffRequest() + } + } + + private func checkHandoffRequest() { + guard let handoff = sessionHandoff else { return } + DispatchQueue.global(qos: .utility).async { + guard handoff.consume() else { return } + DispatchQueue.main.async { MainWindow.shared.open() } + } } /// Clicking the Dock icon (re)opens the main window — the standard macOS @@ -156,7 +187,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func refresh() { pollStateFile() repaint() - consumeHandoffRequest() } /// Stats and decodes the state file on a background queue, publishing the diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 7c26f19..2b5540c 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -13,7 +13,8 @@ import ServiceManagement /// `LaunchVisibility` and docs/adr/0014-login-item-launch-marker.md. /// /// Registering an agent `exec`s the app immediately (`RunAtLoad`), so both -/// `toggle()` and the migration below can spawn a second copy of a running app. +/// `set(enabled:)` and the migration below can spawn a second copy of a running +/// app. /// That is caught at startup by the instance lock in main.swift, not here — the /// duplicate is a *process* problem and this type has no way to see it. enum LoginItem { @@ -56,7 +57,8 @@ enum LoginItem { /// the exact outcome the unmarked flag exists to prevent. private static let legacyRetractedKey = "dezhban.loginItemLegacyRetracted" - /// What a `toggle()` actually achieved, so the UI can say something true. + /// What a `set(enabled:)` actually achieved, so the UI can say something + /// true. /// /// A plain `Bool` could not: `register()` reports "the user has to approve /// this in System Settings" as a *status* rather than an error, and the one @@ -160,11 +162,11 @@ enum LoginItem { /// agent would show "off" while startup kept happening, and leave the user /// no control that reaches the thing launching them. /// - /// This is the value the switch displays *and* the value `toggle()` branches - /// on; they must be the same one. When they were not, an awaiting-approval - /// registration painted the switch ON while `toggle()` still saw "off", so - /// the user's next click re-registered instead of disabling and there was no - /// way to switch login-at-launch off at all. + /// This is the value the switch displays. It must agree with what + /// `set(enabled:)` does, or the two answer different questions about the same + /// switch: an awaiting-approval registration once painted the switch ON while + /// this read "off", so a click meant to disable re-registered instead and + /// there was no way to switch login-at-launch off at all. static var isEnabled: Bool { registered(service) || registered(.mainApp) } /// Sets login-at-launch to `enabled` and reports what actually happened. @@ -210,6 +212,15 @@ enum LoginItem { private static func disable() -> Outcome { UserDefaults.standard.set(true, forKey: userDisabledKey) + // Flushed before the unregister below, because that unregister may get + // this process killed: launchd terminates a loaded job's running process, + // and in a login-started session that process is the app (recorded as an + // open risk in docs/adr/0014-login-item-launch-marker.md). UserDefaults + // does not write through synchronously, so losing this one would leave a + // pending migration retry free to register the agent again on the next + // launch — turning login-at-launch back on behind the user, which is the + // single thing this key exists to prevent. + UserDefaults.standard.synchronize() if registered(service) { unregister(service, what: "login agent") } if registered(.mainApp) { unregister(.mainApp, what: "legacy login item") } if registered(.mainApp) { @@ -256,7 +267,7 @@ enum LoginItem { // account done forever. The symptom is an SMAppService status nobody // reads. This runs unattended, so it has to be the conservative one; an // explicit toggle from Settings is the user's own call and is not gated. - guard Bundle.main.bundleURL.deletingLastPathComponent().path == "/Applications" else { + guard isInStableInstallLocation else { NSLog("DezhbanMenu: not migrating the login item from a non-standard location " + "(\(Bundle.main.bundleURL.path)); the copy in /Applications will do it") return @@ -332,6 +343,32 @@ enum LoginItem { UserDefaults.standard.set(true, forKey: migratedKey) } + /// Whether this bundle lives somewhere it is going to stay. + /// + /// `/Applications` is where every shipping path puts it (the `.pkg`, and the + /// app zip, which unpacks straight into it); `~/Applications` is the + /// system-sanctioned per-user equivalent. Anywhere else — `~/Downloads`, + /// `dist/` — is a copy that is about to move or be deleted, and registering + /// the login agent from it would point launchd at a bundle that stops + /// existing. + /// + /// Symlinks are resolved for the same reason `InstanceLock` resolves them: an + /// install reached through a symlinked directory is still that install, and a + /// literal string comparison silently answered "no" and left the migration + /// undone forever, reported only in a log line. + private static var isInStableInstallLocation: Bool { + let parent = Bundle.main.bundleURL + .resolvingSymlinksInPath() + .standardizedFileURL + .deletingLastPathComponent() + let candidates = [URL(fileURLWithPath: "/Applications")] + + FileManager.default + .urls(for: .applicationDirectory, in: .userDomainMask) + return candidates.contains { + $0.resolvingSymlinksInPath().standardizedFileURL.path == parent.path + } + } + /// Words, not a raw `SMAppService.Status`. It is an imported `NS_ENUM` with no /// `CustomStringConvertible`, so interpolating it put /// `SMAppService.Status(rawValue: 3)` in front of the user — in the very type diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index e3b96dc..3c41472 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -91,9 +91,6 @@ func retractLoginRegistrationsAndExit() { /// Returns the lock on success. The caller must keep it alive for the lifetime of /// the process — the lock IS the open file descriptor. func acquireSessionOwnership() -> InstanceLock? { - // Set for the winner, read by AppDelegate. A hand-off request that arrives - // before the observer exists lands here instead of nowhere. - defer { _ = sessionHandoff } // No bundle identifier means a bare `swift run` binary: no agent could have // spawned it, and nothing to scope a lock to. guard let id = Bundle.main.bundleIdentifier, diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 882a9b9..169fbe7 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -96,7 +96,12 @@ if [ -n "$CONSOLE_UID" ]; then done if [ ! -f "$errand_done" ]; then echo "note: retracting the login item did not finish in 15s; continuing" >&2 + # The subshell AND what it started. Killing only the subshell leaves the + # DezhbanMenu it launched running, and the very next statement deletes + # the bundle out from under it — the thing the `pkill` above exists to + # avoid, reintroduced on the one path this timeout is here for. kill -9 "$errand" >/dev/null 2>&1 || true + pkill -x DezhbanMenu >/dev/null 2>&1 || true fi rm -f "$errand_done" wait "$errand" >/dev/null 2>&1 || true From e65a1936afcffa9d2f2155fcb403fd55e230df7b Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Fri, 21 Aug 2026 23:51:28 +0330 Subject: [PATCH 08/36] fix(gui): make the hand-off a claim, not a read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh review round: no high-severity defect, and the two findings that matter are both in machinery this branch added while fixing earlier findings. The de-duplication added last round was itself racy. The notification handler discarded the file while the backstop stat-ed and removed it on a background queue, so a backstop tick that had already read the timestamp still reported success after the discard — and the window opened twice, which is what the discard was added to prevent. Both signals describe one request, so taking it is now a claim: exactly one removeItem can succeed, so exactly one caller is told it owns the request and the other is told it lost. The .lost branch has a test seam rather than a comment claiming it works, since it is the only outcome that arises purely from overlap and it is the whole point of the type. disable() retracted the agent before the legacy item, and the agent unregister is the call ADR-0014 records may get this process killed by launchd. In a login-started session with a stuck legacy item, the app died between the two lines and left the legacy registration in place — still starting the app at login, without the marker, which is the state disable() exists to clear. Legacy first, agent last, for the same reason the flush above them exists. legacyRetractedKey was only ever written by the migration, so retracting through the Settings switch destroyed the fact without recording it and reopened the dead-retry hole from the other side: switch off, switch on, register() fails, and the next launch sees no legacy item and no flag, concludes there was never anything to migrate, and marks the account done with nothing starting the app at login. One helper records it wherever the retraction happens — except the uninstall errand, where recording "the agent still needs registering" would be a lie about an app being deleted. Also: the migration ran synchronously on the main thread during applicationDidFinishLaunching — five blocking XPC round-trips and a register() that forks a process, at a slow login, which is the situation this feature is tuned around. It only affects the next login, so nothing waits on it. Three comments promised the backstop ran on the ordinary 1-second tick, which is a stronger guarantee than the bounded launch-time one it actually has. And the Settings-pane checklist still asserted the toggle sets SMAppService.mainApp to .enabled, which this branch makes false and the new block asserts the opposite of. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 10 ++- docs/contribute/testing.md | 6 +- .../Sources/DezhbanCore/HandoffRequest.swift | 62 +++++++++++++++---- .../Sources/DezhbanMenu/AppDelegate.swift | 38 ++++++++---- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 42 ++++++++++--- gui/macos/Sources/DezhbanMenu/main.swift | 10 +-- .../HandoffRequestTests.swift | 42 +++++++++---- 7 files changed, 156 insertions(+), 54 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 8b5e156..8fb9de3 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -152,9 +152,13 @@ with an argument, the pre-`SMAppService` pattern. because a permanent per-tick stat on the main thread is the hazard `pollStateFile` was restructured to remove and this feature is cosmetic; the window it covers is a launch-time one, and once the observer exists the - notification carries every later hand-off. The notification path discards the - file as it handles it, since the file is written first and would otherwise have - the backstop open the window a second time. Requests carry + notification carries every later hand-off. Both signals describe the same + request, so acting on it is a *claim* — whoever removes the file acts and the + other stands down. Reading the timestamp and removing without checking, which is + what it did first, let the notification handler and the backstop both conclude + they had it and open the window twice: a second `NSApp.activate` half a second + after the first, or a window reopening right after the user closed it. Requests + carry their own freshness: one the incumbent never got to must not be inherited by the *next* app to start and turned into a window nobody asked for, so a stale file is discarded rather than obeyed, and a process that has just taken the lock discards diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index b3a8efb..74daa36 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -957,8 +957,10 @@ end up typing a password. - [ ] **Start at boot** reflects whether the service is registered, flips after install/uninstall (one prompt each, uninstall confirms first), and the uninstall tears rules down before unload. -- [ ] **Launch at login** toggles `SMAppService.mainApp.status` to `.enabled`, and - the app relaunches after a logout/login cycle. +- [ ] **Launch at login** — the login-item checks live with the launch-marker + block earlier in this file, since they are the same mechanism; the switch + registers the *agent* and a correct run leaves `SMAppService.mainApp` + unregistered. - [ ] Guard fields seed from `dezhban config show` values; Apply raises the restart-warning choice; "Save only" writes without restarting. - [ ] **Restart dezhban…** works with nothing else pending: a plain "are you diff --git a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift index 72653ee..0848bfc 100644 --- a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift +++ b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift @@ -12,10 +12,17 @@ import Foundation /// and their launch does nothing visible at all, which is the one outcome the /// hand-off exists to prevent. /// -/// A file closes it because it waits. The incumbent consumes it when it installs -/// the observer *and* on its ordinary once-a-second tick, so a request written at -/// any point is picked up. The notification is kept as the fast path — this is the -/// one that cannot be missed. +/// A file closes it because it waits: the incumbent looks for one when it installs +/// the observer, and again for a few seconds after, so a request written during +/// its own startup is still found. The window being covered is a launch-time one, +/// which is why the looking is bounded rather than permanent — once the observer +/// exists the notification carries every later hand-off. +/// +/// Both signals describe the *same* request, so acting on it is a claim rather +/// than a read: whoever removes the file acts, and everyone else stands down. A +/// plain check-then-remove let the notification and the file-watcher both open the +/// window — a second `NSApp.activate` half a second after the first, or a window +/// reopening right after the user closed it. public struct HandoffRequest { /// How long a request stays meaningful. /// @@ -44,6 +51,19 @@ public struct HandoffRequest { try? Data().write(to: url, options: .atomic) } + /// The result of trying to take a request. + public enum Claim: Equatable { + /// Taken, and recent enough to act on. + case fresh + /// Taken, but too old to act on — see `freshness`. + case stale + /// There was nothing to take. + case absent + /// There was a request, and somebody else took it first. Whoever did is + /// acting on it, so this caller must not. + case lost + } + /// Removes any request without acting on it. /// /// Called by a process that has just *taken* the lock: anything on disk at @@ -52,18 +72,36 @@ public struct HandoffRequest { try? FileManager.default.removeItem(at: url) } - /// Whether a fresh request is waiting, removing it either way. + /// Tries to take the request, and reports whether this caller owns it. /// - /// Consuming even a stale one keeps a file that will never be obeyed from - /// sitting there being re-examined on every tick. - public func consume(now: Date = Date()) -> Bool { + /// The removal is what makes it a claim: exactly one `removeItem` can succeed + /// for a given file, so exactly one caller is told `.fresh` and the other is + /// told `.lost`. Reading the timestamp first and removing afterwards without + /// checking — the first shape of this — let a background check and the + /// notification handler both conclude they had it. + /// + /// A stale request is still taken, so a file that will never be acted on stops + /// being re-examined. + /// `interleaved` exists so the `.lost` branch can be tested at all. It is the + /// one outcome that only occurs when two claimers overlap, and it is also the + /// one that matters — it is what stops both of them acting — so asserting it + /// in a comment rather than a test would be asserting the whole point. + public func claim(now: Date = Date(), interleaved: () -> Void = {}) -> Claim { let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) - guard let attributes else { return false } - try? FileManager.default.removeItem(at: url) - guard let written = attributes[.modificationDate] as? Date else { return false } + guard let attributes else { return .absent } + interleaved() + do { + try FileManager.default.removeItem(at: url) + } catch { + // Gone between the stat and the remove: somebody else claimed it. (A + // genuine permissions failure lands here too, and standing down is the + // safe reading of it — a window that does not open, never two.) + return .lost + } + guard let written = attributes[.modificationDate] as? Date else { return .stale } let age = now.timeIntervalSince(written) // A negative age means a clock change put the file in the future rather // than that it is impossibly fresh; treat it the same as too old. - return age >= 0 && age <= Self.freshness + return age >= 0 && age <= Self.freshness ? .fresh : .stale } } diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index f6fc95c..a94d506 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -92,9 +92,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { statusItem.menu = menu watchdog.start() refresh() - // Move any pre-agent install onto the login LaunchAgent before anything - // reads the launch marker, so the NEXT login is already correct. - LoginItem.migrateFromMainAppRegistration() + // Move any pre-agent install onto the login LaunchAgent. Off the main + // thread: it is up to five blocking SMAppService round-trips over XPC plus + // a register() that forks a process, and the moment it runs is a + // slow-to-start login — the exact situation this whole feature is tuned + // around, and the one where a frozen launch is most visible. It only + // affects the NEXT login, so nothing here waits on it. + DispatchQueue.global(qos: .utility).async { + LoginItem.migrateFromMainAppRegistration() + } // Launching the app shows the app: reaching the main window only through // the menubar dropdown made opening it a two-step discovery problem, and // the menubar item stays available either way. @@ -129,13 +135,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// bothered to tell us — it is standing in for the launch the user performed. @objc private func openWindowRequested() { // The file is written BEFORE the notification is posted, so in the normal - // hand-off it is already on disk and this notification has answered it. - // Leaving it would have the backstop below open the window a second time - // up to a second later — and `MainWindow.open()` activates the app, so - // that is a second focus steal, or a window reopening after the user just - // closed it. - sessionHandoff?.discard() - MainWindow.shared.open() + // hand-off it is already on disk and this notification is answering it. + // Claiming it here is what stops the backstop below answering it too — a + // second `NSApp.activate` up to half a second later, or a window + // reopening after the user just closed it. + // + // `.lost` means the backstop got there first and is opening the window, so + // this stands down. `.absent` means there is no file to coordinate over + // (the write failed, or this is a notification from something else) and the + // fast path is all there is — so it acts. + switch sessionHandoff?.claim() ?? .absent { + case .fresh, .absent: + MainWindow.shared.open() + case .stale, .lost: + break + } } /// The notification's backstop, for the gap before the observer above exists. @@ -163,7 +177,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func checkHandoffRequest() { guard let handoff = sessionHandoff else { return } DispatchQueue.global(qos: .utility).async { - guard handoff.consume() else { return } + // Only `.fresh`: `.lost` means the notification handler claimed it and + // is already opening the window. + guard handoff.claim() == .fresh else { return } DispatchQueue.main.async { MainWindow.shared.open() } } } diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 2b5540c..49ca3f9 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -193,7 +193,7 @@ enum LoginItem { // the stuck-migration path led straight here: switch reads ON, user clicks // it off, clicks it on again, and both are registered. if registered(.mainApp) { - unregister(.mainApp, what: "legacy login item") + retractLegacy() if registered(.mainApp) { return .legacyStuck } } UserDefaults.standard.set(false, forKey: userDisabledKey) @@ -221,8 +221,14 @@ enum LoginItem { // launch — turning login-at-launch back on behind the user, which is the // single thing this key exists to prevent. UserDefaults.standard.synchronize() + // Legacy FIRST, agent last, for the same reason the flush above exists: + // the agent unregister is the call that may get this process killed by + // launchd. Done the other way round, a stuck-legacy install in a + // login-started session lost the app between the two lines and left the + // legacy item registered — still starting the app at login, without the + // marker, which is the state this function exists to clear. + retractLegacy() if registered(service) { unregister(service, what: "login agent") } - if registered(.mainApp) { unregister(.mainApp, what: "legacy login item") } if registered(.mainApp) { // The stuck path. Reported rather than worked around: registering the // agent alongside it would mean two launches at login, one with the @@ -245,6 +251,9 @@ enum LoginItem { static func retractAll() { if registered(service) { unregister(service, what: "login agent") } if registered(.mainApp) { unregister(.mainApp, what: "legacy login item") } + // Deliberately NOT through `retractLegacy`: this runs from the uninstall + // errand, where recording "the agent still needs registering" would be a + // lie about an app that is about to be deleted. } /// Moves an install that registered `SMAppService.mainApp` (every build @@ -281,11 +290,11 @@ enum LoginItem { } if legacyEnabled { - unregister(.mainApp, what: "legacy login item") - // Checked after the attempt rather than trusting it not to throw: what - // matters is whether the old item is actually gone. `registered`, not + // Checks after the attempt rather than trusting it not to throw, and + // records the retraction — see `retractLegacy`. `registered`, not // `legacyEnabled`: a retraction that left it awaiting approval has not // retracted anything. + retractLegacy() if registered(.mainApp) { // Same reasoning as `disable()`'s stuck path — the agent is left // unregistered rather than stacked on top of a live legacy item. @@ -298,11 +307,6 @@ enum LoginItem { markMigrated() return } - // Recorded BEFORE the register below, and this is the whole point of - // the flag: it is what a retry launch has to go on, since by then the - // legacy item is gone and there is nothing else left to tell "this - // account had a login item to migrate" from "this account never did". - UserDefaults.standard.set(true, forKey: legacyRetractedKey) } else if registered(.mainApp) { // Present but not enabled — the user switched it off in System // Settings. Their "off" is the answer: nothing is carried forward and @@ -339,6 +343,24 @@ enum LoginItem { } } + /// Retracts the legacy item and records the fact if it worked. + /// + /// The recording is the point. `legacyRetractedKey` is what tells "this + /// account had a login item and the agent is not up yet" from "this account + /// never had one", and while only the migration wrote it, retracting through + /// the Settings switch destroyed the fact without recording it — reopening the + /// dead-retry hole from the other side. Switch off (legacy gone, nothing + /// recorded), switch on, `register()` fails, and the next launch sees no + /// legacy item and no flag, concludes there was never anything to migrate, + /// and marks the account done with nothing starting the app at login. + private static func retractLegacy() { + guard registered(.mainApp) else { return } + unregister(.mainApp, what: "legacy login item") + guard !registered(.mainApp) else { return } + UserDefaults.standard.set(true, forKey: legacyRetractedKey) + UserDefaults.standard.synchronize() + } + private static func markMigrated() { UserDefaults.standard.set(true, forKey: migratedKey) } diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index 3c41472..48b8aea 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -55,9 +55,9 @@ func makeMainMenu() -> NSMenu { /// The hand-off request beside this install's instance lock, once known. /// /// A global because both ends of the launch need it: `acquireSessionOwnership()` -/// writes it from a losing copy, and `AppDelegate` consumes it — including on the -/// ordinary 1-second tick, which is what makes a request that arrived before the -/// observer existed still get honoured. +/// writes it from a losing copy, and `AppDelegate` claims it — from the +/// notification handler, and from a bounded backstop after launch, which is what +/// makes a request that arrived before the observer existed still get honoured. var sessionHandoff: HandoffRequest? /// Retracts every login registration and exits, without starting the app. @@ -147,7 +147,9 @@ func acquireSessionOwnership() -> InstanceLock? { // The file first, then the notification. The notification is the // fast path but is never queued, and the incumbent may still be // starting up with no observer installed — the file is the one - // that waits, and its ordinary once-a-second tick finds it. + // that waits, and the incumbent's launch-time backstop finds it. + // Whichever of the two gets there claims it, so the window opens + // once (see HandoffRequest). sessionHandoff?.post() DistributedNotificationCenter.default().postNotificationName( NSNotification.Name(AppDelegate.openWindowNotification), diff --git a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift index cb66561..25e80b0 100644 --- a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift @@ -11,27 +11,27 @@ struct HandoffRequestTests { } /// The whole point: a request written while nobody was observing is still - /// there to be found. - @Test func aPostedRequestIsConsumedOnce() throws { + /// there to be found — and found once. + @Test func aPostedRequestIsClaimedOnce() throws { let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } let request = HandoffRequest(url: dir.appendingPathComponent("a.handoff")) request.post() - #expect(request.consume()) - #expect(!request.consume()) + #expect(request.claim() == .fresh) + #expect(request.claim() == .absent) } - /// Nothing waiting means nothing to do — this is asked once a second, so it - /// must be quiet. + /// Nothing waiting means nothing to do, which is the ordinary case every time + /// the backstop looks. @Test func noRequestIsNotARequest() throws { let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } - #expect(!HandoffRequest(url: dir.appendingPathComponent("b.handoff")).consume()) + #expect(HandoffRequest(url: dir.appendingPathComponent("b.handoff")).claim() == .absent) } /// A request outlives its click only briefly. If the incumbent died before - /// consuming one, the next app to start must not inherit it and open a window + /// claiming one, the next app to start must not inherit it and open a window /// nobody asked for. @Test func aStaleRequestIsDiscardedNotObeyed() throws { let dir = try tempDir() @@ -40,8 +40,8 @@ struct HandoffRequestTests { request.post() let later = Date().addingTimeInterval(HandoffRequest.freshness + 5) - #expect(!request.consume(now: later)) - // Consumed anyway, so it is not re-examined on every tick forever. + #expect(request.claim(now: later) == .stale) + // Taken anyway, so a file that will never be acted on stops being looked at. #expect(!FileManager.default.fileExists(atPath: request.url.path)) } @@ -54,7 +54,7 @@ struct HandoffRequestTests { request.post() let earlier = Date().addingTimeInterval(-3600) - #expect(!request.consume(now: earlier)) + #expect(request.claim(now: earlier) == .stale) } /// `discard()` is what a process that has just taken the lock calls: whatever @@ -66,7 +66,25 @@ struct HandoffRequestTests { request.post() request.discard() - #expect(!request.consume()) + #expect(request.claim() == .absent) + } + + /// Two claimers overlapping on one request — the notification handler and the + /// launch-time backstop, which is the pair this type exists to arbitrate. The + /// loser must be told it lost, not that there was nothing there: only that + /// distinction stops it opening a second window half a second after the first, + /// or reopening one the user has just closed. + @Test func anOverlappingClaimerIsToldItLost() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let request = HandoffRequest(url: dir.appendingPathComponent("f.handoff")) + + request.post() + // Stands in for the other claimer winning between this one's stat and its + // remove — the only way `.lost` can arise, and the reason `claim` takes + // the hook. + let claim = request.claim(interleaved: { request.discard() }) + #expect(claim == .lost) } /// Scoped per install, like the lock it sits beside — two installs may From 74c2cbf3d07679f09d66afc4601456691d9a3856 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 00:15:53 +0330 Subject: [PATCH 09/36] fix(gui): debounce the hand-off effect instead of over-refusing signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighth review round: no high-severity defect, and the findings are the residue of the claim protocol added last round. Refusing to act was the wrong half to tighten. The duplicate writes the file and then posts the notification, so a backstop tick landing between those two calls claims the file and opens the window, and the notification then finds nothing — and standing down on that would turn a hand-off into the silent no-op the whole mechanism exists to prevent, which is worse than a duplicate window. Same for a stale file: freshness exists to stop a dead predecessor's request being inherited, but a notification arriving is itself proof somebody is alive and asking, so the pre-file design would have honoured it. The notification now acts on everything except .lost, the backstop still acts only on .fresh, and the effect is debounced — an open within three seconds of a previous hand-off open is dropped. Debouncing what the user actually notices is cheaper and safer than making two asynchronous signals agree. set(enabled:) ran on the main thread from a SwiftUI setter, and it grew to six-to-eight blocking SMAppService round-trips plus an unregister — the same cost that had the migration moved off-main one commit ago, with the reasoning not carried across. Clicking the login switch could beachball the Settings window, and on the disable path launchd may terminate the app partway through, so the main thread is the last thing to be holding. The switch moves where the user put it immediately and is corrected from the outcome when it lands. Also O_CLOEXEC on the lock file. Foundation's Process spawns with POSIX_SPAWN_CLOEXEC_DEFAULT and every subprocess goes through it, so nothing leaks the descriptor today — but the lock IS that descriptor, a child holding it would lock the app's own successor out permanently with nothing to release it, and resting that on an unstated implementation detail of a framework is not worth the two tokens it costs to fix. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +- docs/adr/0014-login-item-launch-marker.md | 15 ++++- .../Sources/DezhbanCore/InstanceLock.swift | 11 +++- .../Sources/DezhbanMenu/AppDelegate.swift | 62 ++++++++++++++----- .../Sources/DezhbanMenu/SettingsView.swift | 22 ++++++- 5 files changed, 90 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ceba3c..b402d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,8 @@ current as you land changes. next click turn login-at-launch *on*. If you had switched Dezhban off under System Settings → General → Login Items, upgrading leaves it off; and a copy of the app run from somewhere other than `/Applications` no longer claims the - login item for a location it is about to be moved out of. + login item for a location it is about to be moved out of. The login switch also + no longer freezes the Settings window while macOS thinks about it. ## [0.11.0] - 2026-08-21 diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 8fb9de3..9fc9abe 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -157,8 +157,19 @@ with an argument, the pre-`SMAppService` pattern. other stands down. Reading the timestamp and removing without checking, which is what it did first, let the notification handler and the backstop both conclude they had it and open the window twice: a second `NSApp.activate` half a second - after the first, or a window reopening right after the user closed it. Requests - carry + after the first, or a window reopening right after the user closed it. + + The claim settles ownership; it cannot settle everything, and trying to make it + do so was the wrong instinct. The duplicate writes the file and *then* posts, so + the two signals can pass each other in ways where both callers legitimately + conclude they should act — and refusing to act on the ambiguous ones turns a + hand-off into the silent no-op the mechanism exists to prevent, which is the + worse failure of the two. So the notification acts on everything except `.lost`, + the backstop acts only on `.fresh`, and the *effect* is debounced: an open within + three seconds of a previous hand-off open is dropped. Debouncing what the user + notices is cheaper and safer than making two asynchronous signals agree. + + Requests carry their own freshness: one the incumbent never got to must not be inherited by the *next* app to start and turned into a window nobody asked for, so a stale file is discarded rather than obeyed, and a process that has just taken the lock discards diff --git a/gui/macos/Sources/DezhbanCore/InstanceLock.swift b/gui/macos/Sources/DezhbanCore/InstanceLock.swift index 21f6160..2ebf4a4 100644 --- a/gui/macos/Sources/DezhbanCore/InstanceLock.swift +++ b/gui/macos/Sources/DezhbanCore/InstanceLock.swift @@ -98,7 +98,16 @@ public final class InstanceLock { public func acquire() -> Acquisition { guard fd < 0 else { return .acquired } - let opened = open(url.path, O_CREAT | O_RDWR, 0o644) + // O_CLOEXEC because the lock IS this descriptor: a child that inherited it + // would hold the lock past this app's death and lock its own successor out + // — permanently, since nothing would ever release it. Every subprocess the + // app starts goes through Foundation's `Process`, which spawns with + // POSIX_SPAWN_CLOEXEC_DEFAULT on macOS and so does not leak it today. That + // makes this insurance rather than a fix, which is exactly why it belongs + // here: the safety currently rests on an implementation detail of a + // framework, stated nowhere, and the failure it would cause is an app that + // never starts again. + let opened = open(url.path, O_CREAT | O_RDWR | O_CLOEXEC, 0o644) if opened < 0 { return .unavailable("open(\(url.path)): \(String(cString: strerror(errno)))") } diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index a94d506..2c1ab9f 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -22,6 +22,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var updateTimer: Timer? /// Runs only for a few seconds after launch — see `startHandoffBackstop`. private var handoffTimer: Timer? + /// When a hand-off last opened the window, so two signals for one request + /// cannot open it twice — see `openForHandoff`. + private var lastHandoffOpenAt: Date? private var snapshot: Snapshot? private var lastMtime: Date? private var lastIconKey: String? @@ -134,24 +137,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// already owning the session. Opening the window is the whole reason it /// bothered to tell us — it is standing in for the launch the user performed. @objc private func openWindowRequested() { - // The file is written BEFORE the notification is posted, so in the normal - // hand-off it is already on disk and this notification is answering it. - // Claiming it here is what stops the backstop below answering it too — a - // second `NSApp.activate` up to half a second later, or a window - // reopening after the user just closed it. + // `.lost` is the one stand-down: the backstop claimed the file first and is + // opening the window. // - // `.lost` means the backstop got there first and is opening the window, so - // this stands down. `.absent` means there is no file to coordinate over - // (the write failed, or this is a notification from something else) and the - // fast path is all there is — so it acts. + // Everything else acts. `.absent` covers both "the file write failed" and + // the microsecond between the duplicate writing the file and posting this + // — refusing it would turn a hand-off into the silent no-op the whole + // mechanism exists to prevent. `.stale` is actionable *here* though not in + // the backstop: freshness guards against inheriting a dead predecessor's + // file, and a notification arriving is itself proof somebody is alive and + // asking right now, even if this process was wedged for longer than the + // freshness window. Both of those can therefore double up with a backstop + // tick, which is what `openForHandoff`'s debounce is for. switch sessionHandoff?.claim() ?? .absent { - case .fresh, .absent: - MainWindow.shared.open() - case .stale, .lost: + case .fresh, .absent, .stale: + openForHandoff() + case .lost: break } } + /// Opens the window for a hand-off, at most once per request. + /// + /// The claim in `HandoffRequest` settles who *owns* a request; this settles the + /// residue, which the claim cannot: the two signals for one request can pass + /// each other such that both legitimately conclude they should act. Debouncing + /// the effect is cheaper and safer than trying to make two asynchronous signals + /// agree — and the effect is what the user notices, since `MainWindow.open()` + /// calls `NSApp.activate(ignoringOtherApps:)` and so a duplicate is a second + /// focus steal, or a window reopening just after they closed it. + private func openForHandoff() { + let now = Date() + if let last = lastHandoffOpenAt, now.timeIntervalSince(last) < Self.handoffDebounce { + return + } + lastHandoffOpenAt = now + MainWindow.shared.open() + } + + /// Long enough to cover the gap between a notification and a backstop tick + /// (0.5s), short enough that two genuinely separate launches both get a window. + private static let handoffDebounce: TimeInterval = 3 + /// The notification's backstop, for the gap before the observer above exists. /// /// Bounded on purpose. The window it covers is a launch-time one — the lock is @@ -176,11 +203,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func checkHandoffRequest() { guard let handoff = sessionHandoff else { return } - DispatchQueue.global(qos: .utility).async { - // Only `.fresh`: `.lost` means the notification handler claimed it and - // is already opening the window. + DispatchQueue.global(qos: .utility).async { [weak self] in + // Only `.fresh` here. `.lost` means the notification handler claimed it + // and is already opening the window; `.stale` means the file outlived + // whoever wrote it, and unlike a notification arrival there is nothing + // to prove anyone is still asking; `.absent` is the ordinary case of + // there being no request at all, which is what almost every tick sees. guard handoff.claim() == .fresh else { return } - DispatchQueue.main.async { MainWindow.shared.open() } + DispatchQueue.main.async { self?.openForHandoff() } } } diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 499ac60..41fe019 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -789,9 +789,25 @@ struct SettingsView: View { // still hold it for the user's approval, and there is one path // where only they can clear the old login item. A switch that // snaps back with no explanation reads as a bug. - let outcome = LoginItem.set(enabled: wanted) - loginEnabled = outcome.isOn - status = outcome.message + // + // Off the main thread: `set(enabled:)` is six to eight blocking + // SMAppService round-trips over XPC plus an unregister, and this + // runs from a SwiftUI setter, so doing it inline beachballs the + // Settings window. It is the same cost that had the migration moved + // off-main in AppDelegate. The switch moves immediately to where the + // user put it and is corrected from the outcome when it lands — and + // on the disable path launchd may terminate the app partway + // through (ADR-0014's known risk), which is one more reason not to + // be holding the main thread while it happens. + loginEnabled = wanted + status = wanted ? "Registering the login item…" : "Removing the login item…" + DispatchQueue.global(qos: .userInitiated).async { + let outcome = LoginItem.set(enabled: wanted) + DispatchQueue.main.async { + loginEnabled = outcome.isOn + status = outcome.message + } + } }) } From 1aaa0951808344e7d493eb9cfd2ba022e815df7a Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 00:37:56 +0330 Subject: [PATCH 10/36] fix(gui): serialize login-item mutations, and stop over-refusing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninth review round, no high-severity defect, seven low ones — most of them the two questions this branch keeps having to separate: "is there a registration" and "will anything actually start the app". isEnabled reported ON for a legacy item the user had switched off in System Settings, contradicting the migration's own reading of that exact state ("their off is the answer") and showing a switch that was on while nothing started the app. .requiresApproval means opposite things for the two registrations — for the agent, we asked and macOS wants consent; for the legacy item, which this app never registers any more, it is the signature of the user's off — so isEnabled now asks about the agent's presence and the legacy item's enablement. enable() refused on legacy presence while its justification was "two launches at login", and a .requiresApproval legacy item launches nothing. That made Outcome.legacyStuck's own advice a dead end: removing the item under System Settings leaves mainApp exactly there, so "then switch this on again" hit the same refusal and the agent could never be registered. Moving both the migration and the toggle off the main thread let them interleave: the migration passes its userDisabledKey check, the user switches login-at-launch off, disable() retracts everything, and the migration registers the agent — login-at-launch back on right after being turned off, the single thing that key exists to prevent. Every mutation goes through one serial queue now. The debounce added last round swallowed real work. A claim of .fresh means the caller took a request nobody else had, so it is a distinct launch by definition: two double-clicks a second apart with the window closed in between are two requests and both must be answered. Only the ambiguous signals are debounced now. The uninstall errand exited 0 whichever way it went, and the script discards its output — so a login item macOS refused to retract stayed behind, pointing at a bundle deleted two lines later, while the script printed "files deleted". The status is checked and the closing message says what to do. The script also left the app's preference domain, which is not cosmetic: the migration records that it has run, so a surviving flag means a later install is never migrated onto the agent, silently restoring the very defect this branch fixes. Also seed() still read isEnabled on the main thread, at twice its former cost, which is the same blocking XPC the last two commits moved off it. And O_CLOEXEC insurance on the lock descriptor from the same round. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 +- docs/adr/0014-login-item-launch-marker.md | 22 +++++- docs/contribute/testing.md | 10 ++- .../Sources/DezhbanMenu/AppDelegate.swift | 32 ++++++--- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 68 +++++++++++++++---- .../Sources/DezhbanMenu/SettingsView.swift | 8 ++- gui/macos/Sources/DezhbanMenu/main.swift | 8 ++- packaging/macos/uninstall.sh | 32 ++++++++- 8 files changed, 154 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b402d70..467905e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,11 @@ current as you land changes. System Settings → General → Login Items, upgrading leaves it off; and a copy of the app run from somewhere other than `/Applications` no longer claims the login item for a location it is about to be moved out of. The login switch also - no longer freezes the Settings window while macOS thinks about it. + no longer freezes the Settings window while macOS thinks about it, and reads OFF + rather than ON when the item you see is one you had already switched off in + System Settings. Uninstalling clears Dezhban's saved app preferences too — + leaving them behind meant a later install silently skipped the login-item + migration. ## [0.11.0] - 2026-08-21 diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 9fc9abe..60bc2c6 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -165,9 +165,16 @@ with an argument, the pre-`SMAppService` pattern. conclude they should act — and refusing to act on the ambiguous ones turns a hand-off into the silent no-op the mechanism exists to prevent, which is the worse failure of the two. So the notification acts on everything except `.lost`, - the backstop acts only on `.fresh`, and the *effect* is debounced: an open within - three seconds of a previous hand-off open is dropped. Debouncing what the user - notices is cheaper and safer than making two asynchronous signals agree. + the backstop acts only on `.fresh`, and the ambiguous signals' *effect* is + debounced: an open within three seconds of a previous hand-off open is dropped. + Debouncing what the user notices is cheaper and safer than making two + asynchronous signals agree. + + Only the ambiguous ones, though. A claim of `.fresh` means the caller took a + request nobody else had, so it is a distinct launch by definition — two + double-clicks a second apart with the window closed in between are two requests + and both must be answered. Debouncing on elapsed time alone swallowed the second, + which is the silent no-op again, arrived at from the other direction. Requests carry their own freshness: one the incumbent never got to must not be inherited by the @@ -198,6 +205,15 @@ with an argument, the pre-`SMAppService` pattern. session before deleting the bundle. Root cannot reach another account's launchd session, so other users' entries are named in the closing message instead. + The errand's exit status is load-bearing: `unregister()` only logs a refusal and + the script discards the output, so without it a login item macOS would not + retract stayed behind — pointing at a bundle deleted moments later, unreachable + from then on — while the script reported everything removed. Uninstalling also + clears the app's preference domain, which is not the cosmetic cleanup it looks + like: the migration records that it has run, so a surviving flag means a *later* + install is never migrated onto the agent, silently restoring the defect this ADR + is about. + ### Risks - **A user who had login-at-launch enabled loses it on upgrade.** diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 74daa36..8a9c047 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -801,6 +801,10 @@ task gui:build && open dist/Dezhban.app login" there is no window. This is `NSApp.disableRelaunchOnLogin()`; without it, LaunchServices relaunches the app with no arguments and races the agent for the lock. +- [ ] **Two hand-offs in quick succession both open the window.** The debounce + must not swallow real work: with the app running from a `--background` login + launch, double-click it (window opens), ⌘W to close, and double-click again + within a second or two. The window must open **both** times. - [ ] **A hand-off that arrives before the app is observing still works.** The race the `HandoffRequest` file exists for: log out and back in and double-click the app in `/Applications` as early as you can, while it is @@ -860,7 +864,11 @@ task gui:build && open dist/Dezhban.app `launchctl bootout` alone only unloads it for the current boot — the reboot is what distinguishes a real retraction (the `--unregister-login-item` errand the script runs as the console user) from - an unload that comes back. + an unload that comes back. `defaults read com.behnam-rk.dezhban.app` must + also fail afterwards: a surviving migration flag means a later install is + never moved onto the agent. And if macOS *does* refuse the retraction, the + script must say so — the closing warning naming System Settings, not a clean + "files deleted". - [ ] **The login agent registers from an ad-hoc-signed build.** Only reachable on a real install: `build-app.sh` signs with `codesign -s -`, and `SMAppService.agent` registration goes through launchd's validation of the diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 2c1ab9f..edfbeba 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -150,25 +150,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // freshness window. Both of those can therefore double up with a backstop // tick, which is what `openForHandoff`'s debounce is for. switch sessionHandoff?.claim() ?? .absent { - case .fresh, .absent, .stale: - openForHandoff() + case .fresh: + // Definitively ours: some duplicate wrote this and nobody else took + // it. Always acted on, never debounced. + openForHandoff(definite: true) + case .absent, .stale: + // Ambiguous, and these are the two that can double up with a backstop + // tick, so they are the ones the debounce is for. + openForHandoff(definite: false) case .lost: break } } - /// Opens the window for a hand-off, at most once per request. + /// Opens the window for a hand-off. /// /// The claim in `HandoffRequest` settles who *owns* a request; this settles the /// residue, which the claim cannot: the two signals for one request can pass /// each other such that both legitimately conclude they should act. Debouncing /// the effect is cheaper and safer than trying to make two asynchronous signals /// agree — and the effect is what the user notices, since `MainWindow.open()` - /// calls `NSApp.activate(ignoringOtherApps:)` and so a duplicate is a second - /// focus steal, or a window reopening just after they closed it. - private func openForHandoff() { + /// calls `NSApp.activate(ignoringOtherApps:)`, so a duplicate is a second focus + /// steal or a window reopening just after they closed it. + /// + /// `definite` is what keeps the debounce from swallowing real work. A claim of + /// `.fresh` means this caller took a request nobody else had, so it is a + /// distinct launch by definition — two double-clicks a second apart, with the + /// window closed in between, are two requests and must both be answered. + /// Debouncing those on elapsed time alone made the second one the silent no-op + /// this whole mechanism exists to prevent. Only the ambiguous signals, which + /// may be describing a request another caller already handled, are debounced. + private func openForHandoff(definite: Bool) { let now = Date() - if let last = lastHandoffOpenAt, now.timeIntervalSince(last) < Self.handoffDebounce { + if !definite, + let last = lastHandoffOpenAt, + now.timeIntervalSince(last) < Self.handoffDebounce { return } lastHandoffOpenAt = now @@ -210,7 +226,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // to prove anyone is still asking; `.absent` is the ordinary case of // there being no request at all, which is what almost every tick sees. guard handoff.claim() == .fresh else { return } - DispatchQueue.main.async { self?.openForHandoff() } + DispatchQueue.main.async { self?.openForHandoff(definite: true) } } } diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 49ca3f9..e18268f 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -118,6 +118,18 @@ enum LoginItem { } } + /// Every mutation runs here, one at a time. + /// + /// Both callers are off the main thread now — the Settings switch, so a click + /// cannot beachball the window on six blocking XPC round-trips, and the + /// migration, so a slow login is not held up by it — which made them able to + /// interleave. The bad one: the migration passes its `userDisabledKey` check, + /// the user switches login-at-launch off, `disable()` retracts everything, and + /// the migration then registers the agent. Login-at-launch back on immediately + /// after being turned off is the single thing that key exists to prevent, so + /// the two paths cannot be allowed to overlap. + private static let queue = DispatchQueue(label: "com.behnam-rk.dezhban.app.loginitem") + private static var service: SMAppService { .agent(plistName: plistName) } private static var agentEnabled: Bool { service.status == .enabled } @@ -167,7 +179,16 @@ enum LoginItem { /// switch: an awaiting-approval registration once painted the switch ON while /// this read "off", so a click meant to disable re-registered instead and /// there was no way to switch login-at-launch off at all. - static var isEnabled: Bool { registered(service) || registered(.mainApp) } + /// + /// Asymmetric between the two, and deliberately. For the **agent**, + /// `.requiresApproval` means we registered it and macOS wants the user's + /// consent — on, pending. For the **legacy** item, which this app never + /// registers any more, `.requiresApproval` is the signature of the user having + /// switched Dezhban off under System Settings → General → Login Items — so it + /// launches nothing, and reporting it as ON contradicted the migration's own + /// reading of that exact state ("their 'off' is the answer") and showed a + /// switch that was on while nothing started the app. + static var isEnabled: Bool { registered(service) || legacyEnabled } /// Sets login-at-launch to `enabled` and reports what actually happened. /// @@ -182,7 +203,7 @@ enum LoginItem { /// never created again. @discardableResult static func set(enabled: Bool) -> Outcome { - enabled ? enable() : disable() + queue.sync { enabled ? enable() : disable() } } private static func enable() -> Outcome { @@ -192,10 +213,16 @@ enum LoginItem { // `disable()` and the migration both refuse it; this refused nothing, and // the stuck-migration path led straight here: switch reads ON, user clicks // it off, clicks it on again, and both are registered. - if registered(.mainApp) { - retractLegacy() - if registered(.mainApp) { return .legacyStuck } - } + // Retract any legacy registration, but refuse only if one is still + // *enabled*. The justification for refusing is "two launches at login, + // one with the marker and one without" — and a `.requiresApproval` legacy + // item launches nothing, so refusing on mere presence was stricter than + // its own reason. It also made `Outcome.legacyStuck`'s advice a dead end: + // removing the item under System Settings leaves `mainApp` at + // `.requiresApproval`, so "then switch this on again" hit the same refusal + // and the agent could never be registered. + retractLegacy() + if legacyEnabled { return .legacyStuck } UserDefaults.standard.set(false, forKey: userDisabledKey) do { try service.register() @@ -229,7 +256,7 @@ enum LoginItem { // marker, which is the state this function exists to clear. retractLegacy() if registered(service) { unregister(service, what: "login agent") } - if registered(.mainApp) { + if legacyEnabled { // The stuck path. Reported rather than worked around: registering the // agent alongside it would mean two launches at login, one with the // marker and one without, and whichever won the race would decide @@ -248,12 +275,23 @@ enum LoginItem { /// retracts an agent registration — `launchctl bootout` unloads the job for /// this boot and leaves the record that recreates it at the next login — and /// only the app can call it. - static func retractAll() { - if registered(service) { unregister(service, what: "login agent") } - if registered(.mainApp) { unregister(.mainApp, what: "legacy login item") } - // Deliberately NOT through `retractLegacy`: this runs from the uninstall - // errand, where recording "the agent still needs registering" would be a - // lie about an app that is about to be deleted. + @discardableResult + static func retractAll() -> Bool { + queue.sync { + if registered(service) { unregister(service, what: "login agent") } + if registered(.mainApp) { unregister(.mainApp, what: "legacy login item") } + // Deliberately NOT through `retractLegacy`: this runs from the uninstall + // errand, where recording "the agent still needs registering" would be a + // lie about an app that is about to be deleted. + // + // Reported, not swallowed. `unregister` only logs its throw, and the + // uninstaller discards this process's output — so a refusal left the + // Login Items entry behind, pointing at a bundle about to be deleted and + // unreachable afterwards, while the script printed "service + // unregistered, files deleted". The orphan the errand exists to remove, + // now silent. The exit status is what makes it visible. + return !registered(service) && !registered(.mainApp) + } } /// Moves an install that registered `SMAppService.mainApp` (every build @@ -264,6 +302,10 @@ enum LoginItem { /// The attempt is recorded either way, so this runs at most once whether it /// succeeded or not — see `migratedKey`. static func migrateFromMainAppRegistration() { + queue.sync { migrateLocked() } + } + + private static func migrateLocked() { guard !UserDefaults.standard.bool(forKey: migratedKey) else { return } // Only from a bundle that is going to stay put, and deliberately WITHOUT // marking migrated — so the installed copy still does this later. diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 41fe019..9336e6b 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -932,7 +932,13 @@ struct SettingsView: View { refreshTokenCapability() status = "Loading…" canApply = false - loginEnabled = LoginItem.isEnabled + // Off-main for the same reason `set(enabled:)` is: this is two blocking + // SMAppService status reads over XPC (it was one before the agent), and + // opening the Settings pane should not hitch on them. + DispatchQueue.global(qos: .userInitiated).async { + let enabled = LoginItem.isEnabled + DispatchQueue.main.async { loginEnabled = enabled } + } notifyPrefs = NotificationManager.prefs checkUpdatesEnabled = UpdateChecker.isEnabled launchVisibility = LaunchPreference.current diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index 48b8aea..dba61ab 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -74,8 +74,12 @@ var sessionHandoff: HandoffRequest? /// the app competing for the session, it is a one-shot errand, and it must work /// while the app is running — which is exactly when the uninstaller finds it. func retractLoginRegistrationsAndExit() { - LoginItem.retractAll() - exit(0) + // The exit status is the whole channel. `unregister()` only logs its throw and + // the uninstaller discards this process's output, so a refusal used to leave + // the Login Items entry behind — pointing at a bundle about to be deleted, and + // unreachable afterwards — while the script printed "service unregistered, + // files deleted". + exit(LoginItem.retractAll() ? 0 : 1) } /// Exits if another copy of this install already owns the session. diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 169fbe7..116108e 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -22,6 +22,7 @@ PLIST=/Library/LaunchDaemons/dezhban.plist SHARE_DIR=/usr/local/share/dezhban LOGIN_AGENT=com.behnam-rk.dezhban.app.login APP_BUNDLE_ID=com.behnam-rk.dezhban.app +LOGIN_ITEM_STUCK=0 if [ "$(id -u)" -ne 0 ]; then echo "error: run as root — sudo sh $0" >&2 @@ -84,9 +85,12 @@ if [ -n "$CONSOLE_UID" ]; then errand_done="${TMPDIR:-/tmp}/dezhban-uninstall-errand.$$" rm -f "$errand_done" ( - launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ - "$APP/Contents/MacOS/DezhbanMenu" --unregister-login-item >/dev/null 2>&1 - : >"$errand_done" + if launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ + "$APP/Contents/MacOS/DezhbanMenu" --unregister-login-item >/dev/null 2>&1; then + echo ok >"$errand_done" + else + echo failed >"$errand_done" + fi ) & errand=$! waited=0 @@ -103,6 +107,14 @@ if [ -n "$CONSOLE_UID" ]; then kill -9 "$errand" >/dev/null 2>&1 || true pkill -x DezhbanMenu >/dev/null 2>&1 || true fi + # The status matters. The app only logs a refused unregister, and this + # script discards its output — so without checking, a login item macOS + # would not retract stayed behind, pointing at a bundle deleted two lines + # later and unreachable from then on, while the closing message claimed + # everything was removed. + if [ "$(cat "$errand_done" 2>/dev/null)" = "failed" ]; then + LOGIN_ITEM_STUCK=1 + fi rm -f "$errand_done" wait "$errand" >/dev/null 2>&1 || true fi @@ -121,6 +133,14 @@ if [ -n "$CONSOLE_UID" ]; then if [ -n "$CONSOLE_HOME" ] && [ -d "$CONSOLE_HOME" ]; then rm -rf "$CONSOLE_HOME/Library/Application Support/$APP_BUNDLE_ID" fi + # The app's preferences, for the same reason. These are not cosmetic: the + # migration that moves an old LaunchServices login item onto the login agent + # records that it has run, so a surviving flag means a LATER install is never + # migrated — silently restoring the "Open minimized" bug the agent exists to + # fix. Written by the user's own cfprefsd, so it is deleted through defaults as + # that user rather than by unlinking the plist under them. + launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ + defaults delete "$APP_BUNDLE_ID" >/dev/null 2>&1 || true fi rm -rf "$APP" @@ -150,6 +170,12 @@ rm -rf "$SHARE_DIR" echo echo "dezhban uninstalled — rules removed, service unregistered, files deleted." +if [ "$LOGIN_ITEM_STUCK" = "1" ]; then + echo + echo "warning: macOS would not retract the login item. Nothing will start" + echo " Dezhban (the app is gone), but the entry remains — remove" + echo " \"Dezhban\" under System Settings > General > Login Items." +fi echo echo "If any OTHER account on this Mac ran the app, its login agent is still" echo "registered there — root cannot reach another user's launchd session. Nothing" From d16bd83b18ecae525b3cf9d3cc3d9c6097b17abc Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 00:58:23 +0330 Subject: [PATCH 11/36] fix: stop writing a root marker to a guessable path, and report every failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenth review round. The two mediums are both in the uninstaller machinery added over the last two rounds. The errand's completion marker was "${TMPDIR:-/tmp}/dezhban-uninstall-errand.$$", written by root. Under `sudo sh` TMPDIR is frequently unset, so that is a predictable name in a world-writable directory: an unprivileged local user can pre-plant a symlink and have the echo write through it as root. The sticky bit stops them deleting our file, not creating one first, and the `rm -f` only unlinked their symlink and left the window open to try again. It goes in a root-owned mktemp -d now. The other one undid the check it sat next to. On the timeout path no marker is ever written, so the "failed" test read false and the script printed a clean "files deleted" while the registration was still on file — the silent orphan that check exists to prevent, on the one path the timeout is there for. Same for an app bundle already dragged to the Trash: only the app can call SMAppService.unregister(), so with it gone the entry cannot be retracted by anything, ever, and the script said everything was removed. Three distinct outcomes now, each named in the closing warning. seed() was moved off the main thread last round and runs on every didBecomeActive — which macOS delivers *during* a login-item change, since it surfaces System Settings or an approval prompt. So a read could start before a click and land after it: click off, completion writes false, stale read writes true, and the switch reads ON with nothing starting the app at login. Both paths are stamped with a click revision. And the notification path accepted a hand-off with no file behind it, without bound. DistributedNotificationCenter has no sender authentication and both the name and the object are derivable, so any process running as this user could activate the app once per debounce interval indefinitely, reopening a window the moment it was closed. The exemption exists only for the microsecond between a duplicate writing the file and posting, so it is now bounded to the launch window where that can happen; outside it, a file is required. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +- docs/adr/0014-login-item-launch-marker.md | 10 ++++ docs/contribute/testing.md | 5 ++ .../Sources/DezhbanMenu/AppDelegate.swift | 17 +++++- .../Sources/DezhbanMenu/SettingsView.swift | 25 ++++++++- packaging/macos/uninstall.sh | 53 +++++++++++++++---- 6 files changed, 99 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 467905e..264b114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,9 @@ current as you land changes. rather than ON when the item you see is one you had already switched off in System Settings. Uninstalling clears Dezhban's saved app preferences too — leaving them behind meant a later install silently skipped the login-item - migration. + migration. If macOS refuses to retract the login item — or the app bundle was + already in the Trash, so nothing can — the uninstaller now says so instead of + reporting a clean removal. ## [0.11.0] - 2026-08-21 diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 60bc2c6..f627910 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -176,6 +176,16 @@ with an argument, the pre-`SMAppService` pattern. and both must be answered. Debouncing on elapsed time alone swallowed the second, which is the silent no-op again, arrived at from the other direction. + And accepting an ambiguous notification is bounded to the launch window, because + `DistributedNotificationCenter` is a system-wide bus with no sender + authentication and both the name and the object are derivable. Unbounded, any + process running as this user could call `MainWindow.open()` — which activates the + app — once per debounce interval indefinitely, reopening a window the moment it + was closed. Requiring a file outside the launch window costs nothing real (the + file is written before the post, so only the microsecond gap between the two + needs the exemption) and removes the channel. The debounce is a rate limit, not a + gate. + Requests carry their own freshness: one the incumbent never got to must not be inherited by the *next* app to start and turned into a window nobody asked for, so a stale file is diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 8a9c047..e2f5448 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -855,6 +855,11 @@ task gui:build && open dist/Dezhban.app `com.behnam-rk.dezhban.app.login` (that is `AssociatedBundleIdentifiers` doing its job — this is the switch a user reaches for to stop the app starting at login, and it is useless if nobody can tell what it governs). +- [ ] **Uninstall over an already-trashed app says so.** Drag + `/Applications/Dezhban.app` to the Trash, then run the uninstaller. It must + finish *and* print the warning naming System Settings — only the app can + retract its own registration, so with the bundle gone the entry cannot be + removed by anything and reporting a clean uninstall would hide it. - [ ] **Uninstall retracts the registration, not just the running job.** With login-at-launch on, run `sudo sh /usr/local/share/dezhban/uninstall.sh`, then confirm `launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index edfbeba..36114df 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -155,8 +155,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // it. Always acted on, never debounced. openForHandoff(definite: true) case .absent, .stale: - // Ambiguous, and these are the two that can double up with a backstop - // tick, so they are the ones the debounce is for. + // Ambiguous: no file to point at, so nothing here proves a duplicate of + // this app wrote it. Accepted only while the launch-time backstop is + // still armed, which is the whole reason to accept them at all — the + // duplicate writes the file and *then* posts, so a backstop tick landing + // between those two calls leaves the notification with nothing to find, + // and refusing it would make a real hand-off a silent no-op. + // + // Outside that window a file is required, because + // `DistributedNotificationCenter` is a system-wide bus with no sender + // authentication and both the name and the object are derivable. Without + // this bound, any process running as this user could call + // `MainWindow.open()` — which activates the app — once per debounce + // interval, forever, reopening a window the moment it was closed. The + // debounce is a rate limit, not a gate. + guard handoffTimer != nil else { break } openForHandoff(definite: false) case .lost: break diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 9336e6b..68cd35a 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -18,6 +18,17 @@ struct SettingsView: View { @EnvironmentObject var state: AppState @State private var loginEnabled = false + /// Bumped by every login-item click, so an asynchronous status read that was + /// already in flight cannot land afterwards and overwrite the result. + /// + /// Both readers of `LoginItem` are off the main thread now, and `seed()` runs + /// on every `didBecomeActiveNotification` — which macOS delivers *during* a + /// login-item change, since it surfaces System Settings or an approval prompt. + /// So the ordering was really available: click off, `seed()` starts a read that + /// still sees the registration, the click's own completion writes `false`, then + /// the stale read writes `true`. The switch then reads ON with nothing starting + /// the app at login until the next activation. + @State private var loginRevision = 0 @State private var notifyPrefs = NotificationManager.prefs @State private var checkUpdatesEnabled = true @State private var launchVisibility: LaunchVisibility = .bootOnly @@ -799,11 +810,15 @@ struct SettingsView: View { // on the disable path launchd may terminate the app partway // through (ADR-0014's known risk), which is one more reason not to // be holding the main thread while it happens. + loginRevision += 1 + let revision = loginRevision loginEnabled = wanted status = wanted ? "Registering the login item…" : "Removing the login item…" DispatchQueue.global(qos: .userInitiated).async { let outcome = LoginItem.set(enabled: wanted) DispatchQueue.main.async { + // A newer click supersedes this one's result. + guard revision == loginRevision else { return } loginEnabled = outcome.isOn status = outcome.message } @@ -934,10 +949,16 @@ struct SettingsView: View { canApply = false // Off-main for the same reason `set(enabled:)` is: this is two blocking // SMAppService status reads over XPC (it was one before the agent), and - // opening the Settings pane should not hitch on them. + // opening the Settings pane should not hitch on them. Stamped with the + // click revision so a read started before a click cannot land after it — + // see `loginRevision`. + let revision = loginRevision DispatchQueue.global(qos: .userInitiated).async { let enabled = LoginItem.isEnabled - DispatchQueue.main.async { loginEnabled = enabled } + DispatchQueue.main.async { + guard revision == loginRevision else { return } + loginEnabled = enabled + } } notifyPrefs = NotificationManager.prefs checkUpdatesEnabled = UpdateChecker.isEnabled diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 116108e..f6a7630 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -22,7 +22,7 @@ PLIST=/Library/LaunchDaemons/dezhban.plist SHARE_DIR=/usr/local/share/dezhban LOGIN_AGENT=com.behnam-rk.dezhban.app.login APP_BUNDLE_ID=com.behnam-rk.dezhban.app -LOGIN_ITEM_STUCK=0 +LOGIN_ITEM_STUCK=none if [ "$(id -u)" -ne 0 ]; then echo "error: run as root — sudo sh $0" >&2 @@ -82,8 +82,19 @@ if [ -n "$CONSOLE_UID" ]; then # perfectly successful run. And no `( sleep N; kill ) &` watchdog — that # leaks its `sleep` past the end of the script and, if it ever fires late, # aims a `kill -9` at a pid the system may have recycled. - errand_done="${TMPDIR:-/tmp}/dezhban-uninstall-errand.$$" - rm -f "$errand_done" + # In a directory this script creates, mode 700, owned by root. The obvious + # "${TMPDIR:-/tmp}/name.$$" is a predictable path in a world-writable + # directory, and under `sudo sh` TMPDIR is often unset — so an unprivileged + # local user could pre-plant a symlink there and have the `echo` below write + # through it AS ROOT. The sticky bit stops them deleting our file; it does + # not stop them creating one first, and `rm -f` would only unlink their + # symlink and leave the window open to try again. + errand_dir=$(mktemp -d "${TMPDIR:-/tmp}/dezhban-uninstall.XXXXXX") || errand_dir="" + if [ -z "$errand_dir" ]; then + echo "note: could not create a private temp directory; skipping the login-item retraction" >&2 + LOGIN_ITEM_STUCK=refused + fi + errand_done="$errand_dir/done" ( if launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ "$APP/Contents/MacOS/DezhbanMenu" --unregister-login-item >/dev/null 2>&1; then @@ -100,6 +111,12 @@ if [ -n "$CONSOLE_UID" ]; then done if [ ! -f "$errand_done" ]; then echo "note: retracting the login item did not finish in 15s; continuing" >&2 + # Reported, not just survived. On this path there is no marker, so the + # "failed" test below is false and the script would print a clean + # "files deleted" while the registration was still on file — the silent + # orphan that test exists to prevent, on the one path the timeout is + # here for. + LOGIN_ITEM_STUCK=timeout # The subshell AND what it started. Killing only the subshell leaves the # DezhbanMenu it launched running, and the very next statement deletes # the bundle out from under it — the thing the `pkill` above exists to @@ -113,10 +130,17 @@ if [ -n "$CONSOLE_UID" ]; then # later and unreachable from then on, while the closing message claimed # everything was removed. if [ "$(cat "$errand_done" 2>/dev/null)" = "failed" ]; then - LOGIN_ITEM_STUCK=1 + LOGIN_ITEM_STUCK=refused fi - rm -f "$errand_done" + rm -rf "$errand_dir" wait "$errand" >/dev/null 2>&1 || true + else + # Only the app can call SMAppService.unregister(), so with the bundle + # already gone — dragged to the Trash before running this — the + # registration cannot be retracted by anything here, or ever again. Saying + # nothing would report a clean uninstall over exactly the orphan this + # errand exists to remove. + LOGIN_ITEM_STUCK=no-app fi launchctl bootout "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1 || true # The app's own per-user directory: the instance lock the GUI takes at startup @@ -170,12 +194,21 @@ rm -rf "$SHARE_DIR" echo echo "dezhban uninstalled — rules removed, service unregistered, files deleted." -if [ "$LOGIN_ITEM_STUCK" = "1" ]; then +case "$LOGIN_ITEM_STUCK" in +none) ;; +*) echo - echo "warning: macOS would not retract the login item. Nothing will start" - echo " Dezhban (the app is gone), but the entry remains — remove" - echo " \"Dezhban\" under System Settings > General > Login Items." -fi + case "$LOGIN_ITEM_STUCK" in + refused) echo "warning: macOS would not retract the login item." ;; + timeout) echo "warning: retracting the login item did not finish in time." ;; + no-app) echo "warning: the app bundle was already gone, so its login item could not" ; + echo " be retracted — only the app itself can do that." ;; + esac + echo " Nothing will start Dezhban (the app is gone), but the entry" + echo " remains — remove \"Dezhban\" under System Settings >" + echo " General > Login Items." + ;; +esac echo echo "If any OTHER account on this Mac ran the app, its login agent is still" echo "registered there — root cannot reach another user's launchd session. Nothing" From 3bc8ba94208b472ae29644025fa6a9b1151440f4 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 01:21:35 +0330 Subject: [PATCH 12/36] fix: drop the age cutoff the discard already covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleventh review round, four findings, all in machinery the previous round added. The mktemp failure branch announced a skip and then did not skip. Control fell through with errand_dir empty, so the marker path was "/done" — the sealed read-only system volume — which can never be written even as root. So the marker never appeared, the poll burned its full 15 seconds, the reason was overwritten from "refused" to "timeout", and the kill -9 then murdered a retraction that had very likely just succeeded. The whole errand block is one if/elif/else now instead of three separate -x tests. The bigger one: gating the fileless notification on the launch backstop contradicted the reason the fileless case was allowed at all, and broke the case this mechanism exists for. A cold login where the incumbent takes longer than 30 seconds to finish starting is exactly the impatient double-click, and there the duplicate's file was written before the observer existed, so the notification found nobody and the file was then discarded for being older than the freshness window. The user's launch did nothing. The fix is to delete the freshness window. It was there so a request its reader never got could not be inherited by the next app to start — but the session owner already discards whatever it finds at the moment it takes the lock, which does that exactly rather than by guessing at an age. With that in place a claimed file was written after this process took the lock by construction, so it is honoured however old it is, and Claim loses its .stale case entirely. The fileless exemption stays bounded to the launch window, which is the only time it can legitimately arise, so last round's unauthenticated-activate channel stays closed. isEnabled did its two status reads outside the mutation queue, so it could observe disable() half-applied — legacy retracted, agent not yet — and report ON. If its main-queue hop landed after the mutation's own completion it overwrote the correct answer, which is the failure the revision stamp was added for and could not close alone. It runs on the queue now, and seed() additionally refuses to write the switch while a mutation is outstanding, since a read started after a click shares that click's revision and passed the equality check. Also the notification handler claimed on the main thread while the backstop deliberately does the identical work off it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 20 +++-- docs/contribute/testing.md | 6 +- .../Sources/DezhbanCore/HandoffRequest.swift | 46 +++++------ .../Sources/DezhbanMenu/AppDelegate.swift | 76 +++++++++--------- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 11 ++- .../Sources/DezhbanMenu/SettingsView.swift | 12 ++- .../HandoffRequestTests.swift | 30 +------ packaging/macos/uninstall.sh | 80 +++++++++---------- 8 files changed, 139 insertions(+), 142 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index f627910..6aa1c8d 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -184,13 +184,19 @@ with an argument, the pre-`SMAppService` pattern. was closed. Requiring a file outside the launch window costs nothing real (the file is written before the post, so only the microsecond gap between the two needs the exemption) and removes the channel. The debounce is a rate limit, not a - gate. - - Requests carry - their own freshness: one the incumbent never got to must not be inherited by the - *next* app to start and turned into a window nobody asked for, so a stale file is - discarded rather than obeyed, and a process that has just taken the lock discards - whatever it finds as belonging to a predecessor. + gate. Both consumers do their claim off the main thread, since it is a stat and an + unlink and a network or relocated home would otherwise block the run loop on the + one path meant to feel instant. + + A request the incumbent never got to must not be inherited by the *next* app to + start and turned into a window nobody asked for. That is handled by the session + owner discarding whatever it finds at the moment it takes the lock — exactly, + where the first design guessed with a 30-second age cutoff. The cutoff only added + a way to be wrong, and it was: a cold login where the incumbent takes longer than + that to finish starting is precisely the impatient-double-click case this + mechanism is written around, and the cutoff threw that request away. So a claimed + file is honoured however old it is, because by construction it was written after + this process took the lock. - **macOS has a second way to start the app at login, and it carries no marker.** "Reopen windows when logging back in" relaunches whatever was running at logout, through LaunchServices, with no arguments. `SMAppService.mainApp` was diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index e2f5448..44de9f9 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -810,7 +810,11 @@ task gui:build && open dist/Dezhban.app double-click the app in `/Applications` as early as you can, while it is still starting from the login agent. The window must open — within about half a second if the notification missed it, from the bounded backstop that - runs for the first few seconds. It must open **once**: no second activation + runs for the first few seconds. This must hold on a *slow* login too: the + request is honoured however long the incumbent took to finish starting, + because the file can only have been written after it took the lock. This must hold on a *slow* login too: the + request is honoured however long the incumbent took to finish starting, + because the file can only have been written after it took the lock. It must open **once**: no second activation a moment later, and a window you close right after must stay closed. Then confirm no `.handoff` file is left in `~/Library/Application Support/com.behnam-rk.dezhban.app/`. diff --git a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift index 0848bfc..a618fc8 100644 --- a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift +++ b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift @@ -24,15 +24,6 @@ import Foundation /// window — a second `NSApp.activate` half a second after the first, or a window /// reopening right after the user closed it. public struct HandoffRequest { - /// How long a request stays meaningful. - /// - /// A request is a live "the user just did something", not a queued command. If - /// the incumbent died before consuming one, the *next* app to start must not - /// inherit it and pop a window nobody asked for, so an old file is discarded - /// rather than obeyed. Generous enough to cover a slow launch, short enough - /// that it cannot outlive the click that caused it. - public static let freshness: TimeInterval = 30 - public let url: URL public init(url: URL) { @@ -52,11 +43,19 @@ public struct HandoffRequest { } /// The result of trying to take a request. + /// + /// There is deliberately no "too old to act on" case. An age cutoff was the + /// first design, to keep a request its intended reader never got from being + /// inherited by the *next* app to start and turned into a window nobody asked + /// for — but `discard()` at lock acquisition already does that, exactly rather + /// than by guessing, so the cutoff only added a way to be wrong. And it was: a + /// cold login where the incumbent takes longer than the cutoff to finish + /// starting is precisely the "user impatient with a slow start" case this whole + /// mechanism is written around, and the cutoff threw that request away. public enum Claim: Equatable { - /// Taken, and recent enough to act on. + /// Taken. Because the session owner discards whatever it finds when it takes + /// the lock, a request seen after that was written by a live duplicate. case fresh - /// Taken, but too old to act on — see `freshness`. - case stale /// There was nothing to take. case absent /// There was a request, and somebody else took it first. Whoever did is @@ -66,8 +65,12 @@ public struct HandoffRequest { /// Removes any request without acting on it. /// - /// Called by a process that has just *taken* the lock: anything on disk at - /// that moment predates its ownership and was meant for a predecessor. + /// Called by a process that has just *taken* the lock: anything on disk at that + /// moment predates its ownership and was meant for a predecessor. This is what + /// makes an age cutoff unnecessary — and it is exact where a cutoff was a guess. + /// The residual window is the microseconds between taking the lock and this + /// call, in which a duplicate could post a request that then gets discarded; + /// orders of magnitude narrower than the launches a 30-second cutoff threw away. public func discard() { try? FileManager.default.removeItem(at: url) } @@ -80,28 +83,21 @@ public struct HandoffRequest { /// checking — the first shape of this — let a background check and the /// notification handler both conclude they had it. /// - /// A stale request is still taken, so a file that will never be acted on stops - /// being re-examined. /// `interleaved` exists so the `.lost` branch can be tested at all. It is the /// one outcome that only occurs when two claimers overlap, and it is also the /// one that matters — it is what stops both of them acting — so asserting it /// in a comment rather than a test would be asserting the whole point. - public func claim(now: Date = Date(), interleaved: () -> Void = {}) -> Claim { - let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) - guard let attributes else { return .absent } + public func claim(interleaved: () -> Void = {}) -> Claim { + guard FileManager.default.fileExists(atPath: url.path) else { return .absent } interleaved() do { try FileManager.default.removeItem(at: url) } catch { - // Gone between the stat and the remove: somebody else claimed it. (A + // Gone between the check and the remove: somebody else claimed it. (A // genuine permissions failure lands here too, and standing down is the // safe reading of it — a window that does not open, never two.) return .lost } - guard let written = attributes[.modificationDate] as? Date else { return .stale } - let age = now.timeIntervalSince(written) - // A negative age means a clock change put the file in the future rather - // than that it is impossibly fresh; treat it the same as too old. - return age >= 0 && age <= Self.freshness ? .fresh : .stale + return .fresh } } diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 36114df..19d2e36 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -137,42 +137,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// already owning the session. Opening the window is the whole reason it /// bothered to tell us — it is standing in for the launch the user performed. @objc private func openWindowRequested() { - // `.lost` is the one stand-down: the backstop claimed the file first and is - // opening the window. + // The claim goes off the main thread, like the backstop's: it is a stat and + // an unlink, and on a network or relocated home — the case `uninstall.sh` + // reads NFSHomeDirectory to accommodate — that blocks the run loop on the + // one path that is supposed to feel instant. // - // Everything else acts. `.absent` covers both "the file write failed" and - // the microsecond between the duplicate writing the file and posting this - // — refusing it would turn a hand-off into the silent no-op the whole - // mechanism exists to prevent. `.stale` is actionable *here* though not in - // the backstop: freshness guards against inheriting a dead predecessor's - // file, and a notification arriving is itself proof somebody is alive and - // asking right now, even if this process was wedged for longer than the - // freshness window. Both of those can therefore double up with a backstop - // tick, which is what `openForHandoff`'s debounce is for. - switch sessionHandoff?.claim() ?? .absent { - case .fresh: - // Definitively ours: some duplicate wrote this and nobody else took - // it. Always acted on, never debounced. - openForHandoff(definite: true) - case .absent, .stale: - // Ambiguous: no file to point at, so nothing here proves a duplicate of - // this app wrote it. Accepted only while the launch-time backstop is - // still armed, which is the whole reason to accept them at all — the - // duplicate writes the file and *then* posts, so a backstop tick landing - // between those two calls leaves the notification with nothing to find, - // and refusing it would make a real hand-off a silent no-op. - // - // Outside that window a file is required, because - // `DistributedNotificationCenter` is a system-wide bus with no sender - // authentication and both the name and the object are derivable. Without - // this bound, any process running as this user could call - // `MainWindow.open()` — which activates the app — once per debounce - // interval, forever, reopening a window the moment it was closed. The - // debounce is a rate limit, not a gate. - guard handoffTimer != nil else { break } - openForHandoff(definite: false) - case .lost: - break + // Whether the fileless fallback is allowed has to be read here though, + // since it is main-thread state. + let backstopArmed = handoffTimer != nil + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + switch sessionHandoff?.claim() ?? .absent { + case .fresh: + // A file, taken by us. The session owner discards whatever it finds + // when it takes the lock, so a file seen afterwards was written by a + // live duplicate however long ago — which is what makes a slow + // launch work rather than being thrown away for being slow. + DispatchQueue.main.async { self?.openForHandoff(definite: true) } + case .absent: + // No file to point at, so nothing here proves a duplicate of this + // app wrote it. Accepted only while the launch-time backstop is + // armed, which is the only window in which it can legitimately + // happen: the duplicate writes the file and *then* posts, so a + // backstop tick landing between those two calls leaves this with + // nothing to find. + // + // Outside that window a file is required, because + // `DistributedNotificationCenter` is a system-wide bus with no + // sender authentication and both the name and the object are + // derivable. Unbounded, any process running as this user could call + // `MainWindow.open()` — which activates the app — once per debounce + // interval forever, reopening a window the moment it was closed. + guard backstopArmed else { return } + DispatchQueue.main.async { self?.openForHandoff(definite: false) } + case .lost: + // The backstop got there first and is opening the window. + break + } } } @@ -233,10 +233,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func checkHandoffRequest() { guard let handoff = sessionHandoff else { return } DispatchQueue.global(qos: .utility).async { [weak self] in - // Only `.fresh` here. `.lost` means the notification handler claimed it - // and is already opening the window; `.stale` means the file outlived - // whoever wrote it, and unlike a notification arrival there is nothing - // to prove anyone is still asking; `.absent` is the ordinary case of + // Only `.fresh`. `.lost` means the notification handler claimed it and + // is already opening the window; `.absent` is the ordinary case of // there being no request at all, which is what almost every tick sees. guard handoff.claim() == .fresh else { return } DispatchQueue.main.async { self?.openForHandoff(definite: true) } diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index e18268f..fcee31e 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -188,7 +188,16 @@ enum LoginItem { /// launches nothing, and reporting it as ON contradicted the migration's own /// reading of that exact state ("their 'off' is the answer") and showed a /// switch that was on while nothing started the app. - static var isEnabled: Bool { registered(service) || legacyEnabled } + static var isEnabled: Bool { + // On the mutation queue, so a read can never observe a change half-applied. + // `disable()` retracts the legacy item and then the agent; a read landing + // between those two saw the agent still registered and reported ON, and if + // its main-queue hop was enqueued after the mutation's own completion it + // overwrote the correct answer with that one — the switch reading ON with + // nothing starting the app at login, which is the failure the revision + // stamp in SettingsView was added for and could not close on its own. + queue.sync { registered(service) || legacyEnabled } + } /// Sets login-at-launch to `enabled` and reports what actually happened. /// diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 68cd35a..6156492 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -29,6 +29,14 @@ struct SettingsView: View { /// the stale read writes `true`. The switch then reads ON with nothing starting /// the app at login until the next activation. @State private var loginRevision = 0 + /// True while a login-item change is in flight. + /// + /// The revision alone was not enough: `seed()` captures the current revision + /// without bumping it, so a status read *started after* a click shares that + /// click's revision and passes the equality check. This is the other half — + /// while a mutation is outstanding, no read may write the switch, whichever + /// order the two happen to complete in. + @State private var loginPending = false @State private var notifyPrefs = NotificationManager.prefs @State private var checkUpdatesEnabled = true @State private var launchVisibility: LaunchVisibility = .bootOnly @@ -812,6 +820,7 @@ struct SettingsView: View { // be holding the main thread while it happens. loginRevision += 1 let revision = loginRevision + loginPending = true loginEnabled = wanted status = wanted ? "Registering the login item…" : "Removing the login item…" DispatchQueue.global(qos: .userInitiated).async { @@ -819,6 +828,7 @@ struct SettingsView: View { DispatchQueue.main.async { // A newer click supersedes this one's result. guard revision == loginRevision else { return } + loginPending = false loginEnabled = outcome.isOn status = outcome.message } @@ -956,7 +966,7 @@ struct SettingsView: View { DispatchQueue.global(qos: .userInitiated).async { let enabled = LoginItem.isEnabled DispatchQueue.main.async { - guard revision == loginRevision else { return } + guard revision == loginRevision, !loginPending else { return } loginEnabled = enabled } } diff --git a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift index 25e80b0..ced0679 100644 --- a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift @@ -23,40 +23,14 @@ struct HandoffRequestTests { } /// Nothing waiting means nothing to do, which is the ordinary case every time - /// the backstop looks. + /// the backstop looks. Distinct from `.lost`, which means somebody else is + /// already acting on one. @Test func noRequestIsNotARequest() throws { let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } #expect(HandoffRequest(url: dir.appendingPathComponent("b.handoff")).claim() == .absent) } - /// A request outlives its click only briefly. If the incumbent died before - /// claiming one, the next app to start must not inherit it and open a window - /// nobody asked for. - @Test func aStaleRequestIsDiscardedNotObeyed() throws { - let dir = try tempDir() - defer { try? FileManager.default.removeItem(at: dir) } - let request = HandoffRequest(url: dir.appendingPathComponent("c.handoff")) - - request.post() - let later = Date().addingTimeInterval(HandoffRequest.freshness + 5) - #expect(request.claim(now: later) == .stale) - // Taken anyway, so a file that will never be acted on stops being looked at. - #expect(!FileManager.default.fileExists(atPath: request.url.path)) - } - - /// A clock that moved backwards must not turn an old request into an - /// impossibly fresh one. - @Test func aRequestFromTheFutureIsNotFresh() throws { - let dir = try tempDir() - defer { try? FileManager.default.removeItem(at: dir) } - let request = HandoffRequest(url: dir.appendingPathComponent("d.handoff")) - - request.post() - let earlier = Date().addingTimeInterval(-3600) - #expect(request.claim(now: earlier) == .stale) - } - /// `discard()` is what a process that has just taken the lock calls: whatever /// is on disk was meant for its predecessor. @Test func discardRemovesWithoutReporting() throws { diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index f6a7630..2d19d0c 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -71,29 +71,38 @@ if [ -n "$CONSOLE_USER" ] && [ "$CONSOLE_USER" != "root" ]; then fi if [ -n "$CONSOLE_UID" ]; then echo "unregistering the login agent for $CONSOLE_USER ..." - if [ -x "$APP/Contents/MacOS/DezhbanMenu" ]; then + if [ ! -x "$APP/Contents/MacOS/DezhbanMenu" ]; then + # Only the app can call SMAppService.unregister(), so with the bundle already + # gone — dragged to the Trash before running this — the registration cannot + # be retracted by anything here, or ever again. Saying nothing would report a + # clean uninstall over exactly the orphan this errand exists to remove. + LOGIN_ITEM_STUCK=no-app + # The marker directory is created by this script, mode 700, owned by root. The + # obvious "${TMPDIR:-/tmp}/name.$$" is a predictable path in a world-writable + # directory, and under `sudo sh` TMPDIR is often unset — so an unprivileged + # local user could pre-plant a symlink there and have the `echo` below write + # through it AS ROOT. The sticky bit stops them deleting our file; it does not + # stop them creating one first, and `rm -f` would only unlink their symlink and + # leave the window open to try again. + elif ! errand_dir=$(mktemp -d "${TMPDIR:-/tmp}/dezhban-uninstall.XXXXXX"); then + # A real skip. Announcing one and falling through anyway left errand_done as + # "/done" — on the sealed read-only system volume, so the marker could never + # be written, the poll burned its full 15s, the reason was overwritten with + # "timeout", and the kill -9 below murdered a retraction that had very likely + # just succeeded. + echo "note: could not create a private temp directory; skipping the login-item retraction" >&2 + LOGIN_ITEM_STUCK=refused + else # Bounded. The errand talks to launchd over XPC, and this runs before # `rm -rf "$APP"` — an uninstaller that hangs here, silently (output is # discarded), leaves the machine mid-removal with no message on screen. # - # Completion is signalled by a file rather than by watching the child: - # an exited-but-unreaped child is a zombie, which `kill -0` still reports - # as alive, so polling the pid would wait out the whole timeout on a - # perfectly successful run. And no `( sleep N; kill ) &` watchdog — that - # leaks its `sleep` past the end of the script and, if it ever fires late, - # aims a `kill -9` at a pid the system may have recycled. - # In a directory this script creates, mode 700, owned by root. The obvious - # "${TMPDIR:-/tmp}/name.$$" is a predictable path in a world-writable - # directory, and under `sudo sh` TMPDIR is often unset — so an unprivileged - # local user could pre-plant a symlink there and have the `echo` below write - # through it AS ROOT. The sticky bit stops them deleting our file; it does - # not stop them creating one first, and `rm -f` would only unlink their - # symlink and leave the window open to try again. - errand_dir=$(mktemp -d "${TMPDIR:-/tmp}/dezhban-uninstall.XXXXXX") || errand_dir="" - if [ -z "$errand_dir" ]; then - echo "note: could not create a private temp directory; skipping the login-item retraction" >&2 - LOGIN_ITEM_STUCK=refused - fi + # Completion is signalled by a file rather than by watching the child: an + # exited-but-unreaped child is a zombie, which `kill -0` still reports as + # alive, so polling the pid would wait out the whole timeout on a perfectly + # successful run. And no `( sleep N; kill ) &` watchdog — that leaks its + # `sleep` past the end of the script and, if it ever fires late, aims a + # `kill -9` at a pid the system may have recycled. errand_done="$errand_dir/done" ( if launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ @@ -111,36 +120,27 @@ if [ -n "$CONSOLE_UID" ]; then done if [ ! -f "$errand_done" ]; then echo "note: retracting the login item did not finish in 15s; continuing" >&2 - # Reported, not just survived. On this path there is no marker, so the - # "failed" test below is false and the script would print a clean + # Reported, not just survived. On this path no marker is ever written, so + # the "failed" test below reads false and the script would print a clean # "files deleted" while the registration was still on file — the silent - # orphan that test exists to prevent, on the one path the timeout is - # here for. + # orphan that test exists to prevent, on the one path the timeout is for. LOGIN_ITEM_STUCK=timeout # The subshell AND what it started. Killing only the subshell leaves the - # DezhbanMenu it launched running, and the very next statement deletes - # the bundle out from under it — the thing the `pkill` above exists to - # avoid, reintroduced on the one path this timeout is here for. + # DezhbanMenu it launched running, and the very next statement deletes the + # bundle out from under it — the thing the `pkill` above exists to avoid, + # reintroduced on the one path this timeout is here for. kill -9 "$errand" >/dev/null 2>&1 || true pkill -x DezhbanMenu >/dev/null 2>&1 || true - fi - # The status matters. The app only logs a refused unregister, and this - # script discards its output — so without checking, a login item macOS - # would not retract stayed behind, pointing at a bundle deleted two lines - # later and unreachable from then on, while the closing message claimed - # everything was removed. - if [ "$(cat "$errand_done" 2>/dev/null)" = "failed" ]; then + elif [ "$(cat "$errand_done" 2>/dev/null)" = "failed" ]; then + # The status matters. The app only logs a refused unregister, and this + # script discards its output — so without checking, a login item macOS + # would not retract stayed behind, pointing at a bundle deleted moments + # later and unreachable from then on, while the closing message claimed + # everything was removed. LOGIN_ITEM_STUCK=refused fi rm -rf "$errand_dir" wait "$errand" >/dev/null 2>&1 || true - else - # Only the app can call SMAppService.unregister(), so with the bundle - # already gone — dragged to the Trash before running this — the - # registration cannot be retracted by anything here, or ever again. Saying - # nothing would report a clean uninstall over exactly the orphan this - # errand exists to remove. - LOGIN_ITEM_STUCK=no-app fi launchctl bootout "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1 || true # The app's own per-user directory: the instance lock the GUI takes at startup From c0d471c74f3612a21aa7aea7455110f5e4fc2157 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 01:36:33 +0330 Subject: [PATCH 13/36] fix(gui): retract a dormant legacy login item instead of leaving it armed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelfth review round. The medium is the consequence of round nine's own fix, which is worth stating plainly: making isEnabled ask whether the legacy item is *enabled* was right for the switch, and it left the present-but-not-enabled case both invisible and unreachable. That case was deliberately left alone, on the reasoning that the user had switched Dezhban off under System Settings and their off should not be undone behind their back. But .requiresApproval is a live registration, not a dead one. Re-approving the entry later has LaunchServices start the app with no --background, breaking "Open minimized" permanently — the migration is marked done and never runs again. And the escape the comment pointed at did not exist: with isEnabled reading OFF, a click routed to enable(), never to the disable() that would have cleared it. The retraction attempt is unconditional now; whether the item was enabled still decides whether the agent is registered afterwards, so the user's off is honoured either way. Two clicks on the login switch could also settle wrong. Each dispatched its own block to a concurrent queue and those raced into LoginItem's serial queue, so click two could be applied first, leaving the registration in click one's state while the UI showed click two's outcome — the switch OFF while the app still starts at login. LoginItem gained an enqueueing form, and since its queue is serial, click order now survives. The uninstaller's completion marker was written with a plain redirect, which truncates before writing. A poll landing in that gap saw the file and read an empty string, which is not "failed", so a retraction macOS had refused was reported as a clean uninstall — one-directional, and towards the silent orphan the check exists to catch. It is written to a side name and moved into place. The no-app warning is also gated on there actually being a registration, so a .pkg install that never launched the app does not send anyone hunting for a Login Items entry that was never created. And the login completion no longer overwrites the shared Settings status line if something else has claimed it since — a window that grew from instant to seconds in this branch. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 12 ++++ docs/contribute/testing.md | 16 +++-- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 65 +++++++++++++------ .../Sources/DezhbanMenu/SettingsView.swift | 25 ++++--- packaging/macos/uninstall.sh | 20 +++++- 5 files changed, 100 insertions(+), 38 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 6aa1c8d..cced0d7 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -248,6 +248,18 @@ with an argument, the pre-`SMAppService` pattern. the user had switched login-at-launch off in Settings, leaving it on with no way to turn it off from the UI. + The retraction attempt is unconditional, even for a legacy item that is present + but not enabled. Leaving that one alone was the earlier choice, on the reasoning + that the user had switched it off in System Settings and their "off" should not be + undone behind their back — but `.requiresApproval` is a *live* registration, not a + dead one. Re-approving "Dezhban" under Login Items later would have LaunchServices + start the app with no `--background`, breaking "Open minimized" permanently, since + the migration is marked done and never runs again. It was also unreachable: + `isEnabled` asks whether the legacy item is *enabled*, so the switch read OFF and + a click routed to `enable()`, never to the `disable()` that could have cleared it. + Retracting it honours the same "off" and removes the way back to the defect; + whether it *was* enabled still decides whether the agent is then registered. + When the legacy item survives the attempt — checked by reading its status after, not by trusting the call not to throw — the agent is deliberately left unregistered. Registering it anyway would mean two launches at login, the diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 44de9f9..f86a65f 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -826,10 +826,18 @@ task gui:build && open dist/Dezhban.app marked done with the agent pointing into `~/Downloads`. Then run the copy in `/Applications`: that one must migrate. - [ ] **A login item the user turned off in System Settings stays off across an - upgrade.** With a pre-agent build, switch Dezhban off under System Settings - → General → Login Items (this leaves `mainApp` at `.requiresApproval`, not - unregistered), then upgrade and launch. Login-at-launch must still be off - and no agent registered. + upgrade — and is cleared, not left dormant.** With a pre-agent build, switch + Dezhban off under System Settings → General → Login Items (this leaves + `mainApp` at `.requiresApproval`, not unregistered), then upgrade and launch. + Login-at-launch must still be off and no agent registered — *and* the entry + must be gone from Login Items. A dormant `.requiresApproval` item is live: + re-approving it there would start the app at login with no marker, and the + migration will not run again to fix it. +- [ ] **Two quick clicks on the login switch settle on the second one.** Click it + off then immediately on (and the reverse). The final switch state must match + the last click *and* the actual registration — + `launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` agreeing with what + the switch shows. Then reopen the pane to confirm it still agrees. - [ ] **Switching login-at-launch off from a login-started session.** Log out and back in so the agent starts the app, then switch Settings → "Open this app at login" **off**. `SMAppService.unregister()` unloads the launchd job diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index fcee31e..73aa330 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -215,6 +215,21 @@ enum LoginItem { queue.sync { enabled ? enable() : disable() } } + /// Same, enqueued rather than blocking, with the result delivered on main. + /// + /// `queue` is serial, so this preserves click order — which the synchronous + /// form did not when each click was dispatched to a *concurrent* queue and the + /// blocks then raced into `sync`. Two quick clicks could reach the serial queue + /// in the wrong order, leaving the registration in click 1's state while the UI + /// applied click 2's outcome: the switch showing OFF while the app still starts + /// at login, which is the lie the whole `Outcome` type exists to prevent. + static func set(enabled: Bool, completion: @escaping (Outcome) -> Void) { + queue.async { + let outcome = enabled ? enable() : disable() + DispatchQueue.main.async { completion(outcome) } + } + } + private static func enable() -> Outcome { // The agent must never be registered beside a live legacy item — that is // two launches at login, one with the marker and one without, and @@ -340,32 +355,40 @@ enum LoginItem { return } - if legacyEnabled { - // Checks after the attempt rather than trusting it not to throw, and - // records the retraction — see `retractLegacy`. `registered`, not - // `legacyEnabled`: a retraction that left it awaiting approval has not - // retracted anything. + if registered(.mainApp) { + // Whether it was *enabled* decides what happens afterwards — that is + // the user's own on/off — but the retraction attempt itself is + // unconditional, and that is the correction here. + // + // The earlier shape left a present-but-not-enabled item alone, on the + // reasoning that the user had switched it off in System Settings and + // their "off" should not be undone behind their back. But + // `.requiresApproval` is a *live* registration, not a dead one: if they + // later re-approve "Dezhban" under Login Items, LaunchServices starts + // the app with no `--background` and "Open minimized" is broken again — + // permanently, because `migratedKey` is set and this never runs a second + // time. And it was unreachable: `isEnabled` asks `legacyEnabled`, so the + // switch read OFF and a click routed to `enable()`, never to the + // `disable()` the old comment pointed at. Retracting it honours the same + // "off" while removing the way back to the defect. + let wasEnabled = legacyEnabled retractLegacy() if registered(.mainApp) { - // Same reasoning as `disable()`'s stuck path — the agent is left - // unregistered rather than stacked on top of a live legacy item. - // The Settings toggle reports the legacy registration, so the user - // can see login-at-launch is on; clearing it is a System Settings - // job, which `Outcome.legacyStuck` spells out when they try. - NSLog("DezhbanMenu: the legacy login item could not be retracted; " - + "leaving login-at-launch as it was. Remove \"Dezhban\" under " - + "System Settings → General → Login Items to move onto the login agent.") + // Stuck. The agent is left unregistered rather than stacked on top + // of a live legacy item — two launches at login, one with the marker + // and one without. Only the user can clear it now. + NSLog("DezhbanMenu: the legacy login item could not be retracted. " + + "Remove \"Dezhban\" under System Settings → General → Login Items; " + + "until then it may start Dezhban at login without the launch marker.") + markMigrated() + return + } + guard wasEnabled else { + // It was off. Nothing is carried forward and the agent is not + // registered; turning login-at-launch on is the user's call. markMigrated() return } - } else if registered(.mainApp) { - // Present but not enabled — the user switched it off in System - // Settings. Their "off" is the answer: nothing is carried forward and - // the agent is not registered. The stale registration is left alone - // rather than retracted behind their back; `isEnabled` reports it, so - // the switch shows it and `disable()` can clear it on request. - markMigrated() - return } else if !UserDefaults.standard.bool(forKey: legacyRetractedKey) { // Nothing was ever registered the old way on this account, so there is // nothing to move onto the agent. Turning login-at-launch on is the diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 6156492..811660b 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -822,16 +822,21 @@ struct SettingsView: View { let revision = loginRevision loginPending = true loginEnabled = wanted - status = wanted ? "Registering the login item…" : "Removing the login item…" - DispatchQueue.global(qos: .userInitiated).async { - let outcome = LoginItem.set(enabled: wanted) - DispatchQueue.main.async { - // A newer click supersedes this one's result. - guard revision == loginRevision else { return } - loginPending = false - loginEnabled = outcome.isOn - status = outcome.message - } + let inProgress = wanted ? "Registering the login item…" : "Removing the login item…" + status = inProgress + // The enqueueing form, so two quick clicks are applied in the order + // they were made — dispatching each to a concurrent queue let them + // race into LoginItem's serial queue and land out of order. + LoginItem.set(enabled: wanted) { outcome in + // A newer click supersedes this one's result. + guard revision == loginRevision else { return } + loginPending = false + loginEnabled = outcome.isOn + // `status` is the whole pane's line and this completion can land + // seconds later, so it is only written if nothing else has + // claimed it since — otherwise a login result overwrites, say, + // "Installing service…" while that install is still running. + if status == inProgress { status = outcome.message } } }) } diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 2d19d0c..0a420bf 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -76,7 +76,14 @@ if [ -n "$CONSOLE_UID" ]; then # gone — dragged to the Trash before running this — the registration cannot # be retracted by anything here, or ever again. Saying nothing would report a # clean uninstall over exactly the orphan this errand exists to remove. - LOGIN_ITEM_STUCK=no-app + # + # But only warn if there IS one. Installing via the .pkg and never launching + # the menubar app registers nothing, and sending that user to hunt for a + # "Dezhban" entry under Login Items that does not exist is its own small + # failure. + if launchctl print "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1; then + LOGIN_ITEM_STUCK=no-app + fi # The marker directory is created by this script, mode 700, owned by root. The # obvious "${TMPDIR:-/tmp}/name.$$" is a predictable path in a world-writable # directory, and under `sudo sh` TMPDIR is often unset — so an unprivileged @@ -105,12 +112,19 @@ if [ -n "$CONSOLE_UID" ]; then # `kill -9` at a pid the system may have recycled. errand_done="$errand_dir/done" ( + # Written to a side name and moved into place, so the marker only ever + # appears complete. `echo failed >"$errand_done"` truncates before it + # writes, and a poll landing in that gap saw the file and `cat`ed an + # empty string — which is not "failed", so a retraction macOS had + # refused was reported as a clean uninstall. One-directional, and + # towards the silent orphan this block exists to catch. if launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ "$APP/Contents/MacOS/DezhbanMenu" --unregister-login-item >/dev/null 2>&1; then - echo ok >"$errand_done" + echo ok >"$errand_done.partial" else - echo failed >"$errand_done" + echo failed >"$errand_done.partial" fi + mv "$errand_done.partial" "$errand_done" ) & errand=$! waited=0 From e75d0ca73559c08dd86369dad0c3c93932ab09d5 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 01:56:17 +0330 Subject: [PATCH 14/36] fix: read the home from /Search, and stop claiming refusals that never happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteenth review round. The home-directory lookup added two rounds ago queried `dscl .`, the local node. A network, LDAP or AD account has no record there, so the lookup returned nothing, the guard skipped the delete, and the app's per-user directory survived the uninstall for exactly the accounts the lookup was written to serve. /Search resolves both, verified on this machine. Two of the uninstaller's warnings were saying things that were not true. The mktemp-failure path reported "macOS would not retract the login item" when nothing had been attempted at all, sending the user to clear an entry by hand that re-running the uninstaller would very likely clear for them; it has its own state and message now. And the already-trashed-bundle warning was gated on `launchctl print`, which reports only *loaded* jobs — so a registration sitting at .requiresApproval, the state the app documents as "the user switched Dezhban off under Login Items", printed a clean "files deleted" over a surviving entry. That gate was added last round to avoid sending .pkg-only users hunting for nothing, so the fix is not to drop it and over-claim in the other direction: the warning always prints when the bundle is missing, and its wording is conditional, because root has no way to read SMAppService status and pretending otherwise is what got this wrong twice. enable()'s catch returned .failed without re-reading the status, which is the same asymmetry .agentStuck was added to close on the disable() side: .failed has isOn == false, so a live registration painted the switch OFF while the app kept starting at login. kSMErrorAlreadyRegistered is the obvious route in — switch stale-OFF, user clicks on, register throws *because* it is already registered. Also deleted the synchronous set(enabled:), which had no callers left: a queue.sync on the type whose job is serializing the mutation paths is an invitation to reintroduce the main-thread beachball. One finding documented rather than fixed: disableRelaunchOnLogin() cannot cover the first logout after an upgrade, since the build being replaced never called it, so the window may open once at that login. Every workaround guesses — suppressing hand-offs near login would break the impatient double-click the hand-off exists for, permanently, to fix one event that has already passed. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 16 +++++++ docs/contribute/testing.md | 7 +++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 21 ++++++--- packaging/macos/uninstall.sh | 44 +++++++++++++------ 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index cced0d7..4ac02cb 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -344,6 +344,22 @@ with an argument, the pre-`SMAppService` pattern. whose `isOn` is false: `unregister()` swallows its throw, so that combination painted the switch OFF while the registration was live and the app kept starting at login. +- **The first logout after upgrading is not covered, once.** + `NSApp.disableRelaunchOnLogin()` is asserted by the *running* app, and the + build being replaced never called it — so at that one logout the system has + already registered the app for a LaunchServices resume relaunch. At the next + login both copies start: the agent's with `--background`, the resume's without. + Either way the window opens under the default `bootOnly` — if the agent wins the + lock, the resume copy loses it, reads as a user launch and hands off, which the + incumbent honours; if the resume copy wins, it opens the window itself. So the + defect this ADR fixes reappears exactly once, on the first login after the + upgrade, and never again. + Not worked around, because every workaround guesses. Suppressing a hand-off for + the first N seconds after login would break the case the hand-off exists for — + an impatient double-click at login is *precisely* that window — and it would + keep doing so on every subsequent login to fix one event that has already + passed. A one-off wrong window beats a permanent rule that throws away real + launches. - **Switching login-at-launch off may terminate the app.** Unverified, and listed here rather than worked around because the workarounds are worse than the symptom. `SMAppService.unregister()` unloads the job from the launchd diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index f86a65f..43ca278 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -793,6 +793,13 @@ task gui:build && open dist/Dezhban.app the duplicate icon this check's predecessor covers. Then set "Open minimized" to **Always** and repeat: the first copy must come forward with **no** window, because always means always. +- [ ] **The first logout after upgrading may open the window once — and only + once.** Expected, not a regression: + [ADR-0014](../adr/0014-login-item-launch-marker.md) records why + `NSApp.disableRelaunchOnLogin()` cannot cover the logout that happened before + the new build ever ran. Upgrade, log out, log back in: a window here is + acceptable. Log out and back in a *second* time — there must be none, and + `pgrep -x DezhbanMenu | wc -l` must be 1. - [ ] **"Reopen windows when logging back in" does not start a second, unmarked copy.** Check that box in System Settings → Desktop & Dock, leave the app running, log out and back in. Exactly one copy must be running and it must diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 73aa330..68e56c9 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -210,12 +210,12 @@ enum LoginItem { /// Turning it OFF retracts both registrations, for the reason `isEnabled` /// reports both. Turning it ON registers only the agent — the legacy one is /// never created again. - @discardableResult - static func set(enabled: Bool) -> Outcome { - queue.sync { enabled ? enable() : disable() } - } - - /// Same, enqueued rather than blocking, with the result delivered on main. + /// Enqueued, with the result delivered on main. + /// + /// There is no synchronous form. There was, and it had no callers left — a + /// `queue.sync` on a type whose whole job is serializing two mutation paths is + /// an invitation to block the main thread on six XPC round-trips, which is the + /// beachball this was moved off-main to avoid. /// /// `queue` is serial, so this preserves click order — which the synchronous /// form did not when each click was dispatched to a *concurrent* queue and the @@ -252,7 +252,14 @@ enum LoginItem { try service.register() } catch { NSLog("DezhbanMenu: could not register the login agent: \(error)") - return .failed(error.localizedDescription) + // Re-read rather than assume the throw means nothing is registered. + // This is the same asymmetry `.agentStuck` was added to close on the + // other side: a throw reported as `.failed` has `isOn == false`, so a + // registration that *is* live paints the switch OFF while the app keeps + // starting at login. `kSMErrorAlreadyRegistered` is the obvious way in — + // the switch stale-OFF, the user clicks it on, and the register throws + // precisely because it is already registered. + return registered(service) ? .agentStuck : .failed(error.localizedDescription) } // Checked, not assumed: `register()` returns without throwing when macOS // is going to make the user approve it, and the switch snapping back with diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 0a420bf..fbace8d 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -77,13 +77,15 @@ if [ -n "$CONSOLE_UID" ]; then # be retracted by anything here, or ever again. Saying nothing would report a # clean uninstall over exactly the orphan this errand exists to remove. # - # But only warn if there IS one. Installing via the .pkg and never launching - # the menubar app registers nothing, and sending that user to hunt for a - # "Dezhban" entry under Login Items that does not exist is its own small - # failure. - if launchctl print "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1; then - LOGIN_ITEM_STUCK=no-app - fi + # Always warned, but worded as a conditional. Gating this on + # `launchctl print` was wrong in the direction that matters: it reports only + # *loaded* jobs, and a registration sitting at `.requiresApproval` — the + # state the app documents as "the user switched Dezhban off under Login + # Items" — is registered without being loaded. So the one case this branch + # exists for printed a clean "files deleted" over a surviving entry. There is + # no root-side way to read SMAppService status, so the honest thing is to say + # "if there is an entry, remove it" rather than to claim either way. + LOGIN_ITEM_STUCK=no-app # The marker directory is created by this script, mode 700, owned by root. The # obvious "${TMPDIR:-/tmp}/name.$$" is a predictable path in a world-writable # directory, and under `sudo sh` TMPDIR is often unset — so an unprivileged @@ -98,7 +100,11 @@ if [ -n "$CONSOLE_UID" ]; then # "timeout", and the kill -9 below murdered a retraction that had very likely # just succeeded. echo "note: could not create a private temp directory; skipping the login-item retraction" >&2 - LOGIN_ITEM_STUCK=refused + # Its own state. Reporting this as "refused" told the user macOS would not + # retract the item and sent them to clear it by hand, when in fact nothing + # was ever attempted and simply running the uninstaller again would very + # likely retract it cleanly. + LOGIN_ITEM_STUCK=not-attempted else # Bounded. The errand talks to launchd over XPC, and this runs before # `rm -rf "$APP"` — an uninstaller that hangs here, silently (output is @@ -166,7 +172,11 @@ if [ -n "$CONSOLE_UID" ]; then # The home directory is asked for, not assumed: a network or mobile account, # or a relocated home, is not under /Users, and hardcoding that path made the # closing "files deleted" line untrue for exactly those users. - CONSOLE_HOME=$(dscl . -read "/Users/$CONSOLE_USER" NFSHomeDirectory 2>/dev/null | + # + # /Search, not the local node. `dscl .` reads only local records, so for a + # network/LDAP/AD account it returns nothing — leaving this to skip the delete + # for precisely the accounts the lookup exists to serve. + CONSOLE_HOME=$(dscl /Search -read "/Users/$CONSOLE_USER" NFSHomeDirectory 2>/dev/null | sed -n 's/^NFSHomeDirectory: //p') if [ -n "$CONSOLE_HOME" ] && [ -d "$CONSOLE_HOME" ]; then rm -rf "$CONSOLE_HOME/Library/Application Support/$APP_BUNDLE_ID" @@ -215,12 +225,18 @@ none) ;; case "$LOGIN_ITEM_STUCK" in refused) echo "warning: macOS would not retract the login item." ;; timeout) echo "warning: retracting the login item did not finish in time." ;; - no-app) echo "warning: the app bundle was already gone, so its login item could not" ; - echo " be retracted — only the app itself can do that." ;; + not-attempted) + echo "warning: the login item was not retracted — the step was skipped." + echo " Running this uninstaller again will most likely clear it." + ;; + no-app) + echo "warning: the app bundle was already gone, so its login item could not" + echo " be retracted — only the app itself can do that." + ;; esac - echo " Nothing will start Dezhban (the app is gone), but the entry" - echo " remains — remove \"Dezhban\" under System Settings >" - echo " General > Login Items." + echo " Nothing will start Dezhban (the app is gone). If a \"Dezhban\"" + echo " entry remains under System Settings > General > Login Items," + echo " remove it there." ;; esac echo From 08b72f04f25837f8995246dd23a0e1612461232d Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 02:19:45 +0330 Subject: [PATCH 15/36] fix(gui): make both migration flags equally durable, and refuse coexistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteenth review round, and my guess that it would be shell polish was wrong — two of these are correctness bugs in the Swift, one of them breaking the invariant ADR-0014 is written around. The migration's two flags had different durability. retractLegacy() writes legacyRetractedKey and flushes; markMigrated() did a plain set. This runs seconds into a login, so a session that ended before cfprefsd wrote it left a legacy item retracted — durably recorded — with the "already migrated" half gone. The next launch then fell through to register() and turned login-at-launch back ON for a user who had it off, which is precisely what must never happen; the mirror loss strands the account with nothing starting the app at login. disable() already flushes before the call that can end the process, and this path has the same obligation. enable() guarded on the legacy item being *enabled* while its own comment said any live registration. That guard was narrowed two rounds ago to keep legacyStuck's advice from being a dead end, on the reasoning that a .requiresApproval item launches nothing — but AssociatedBundleIdentifiers makes one "Dezhban" row in Login Items govern both registrations, so approving that row arms both, and then two copies start at login, one with the marker and one without, racing the lock over whether the window opens. That is the defect this branch removes, and it cannot be traded for a better message. The dead end is real and is now stated instead: if macOS will not retract the old registration, nothing the app can do makes enabling safe. The .agentStuck message was also wrong in the case its own comment names as the main way in. Mapping every throw-with-a-registration to "macOS would not remove the login item, remove it in System Settings" told a user who had just asked to switch login-at-launch ON to go and delete it. The outcome comes from the status now, not from the fact that a throw happened. seed() overwrote the status line with "Loading…" and "Seeded from …" — and it runs on didBecomeActive, which macOS delivers during a login-item change because it surfaces the approval prompt. So the user came back from that prompt and the awaitingApproval and legacyStuck guidance was swallowed: the entire reason Outcome carries a message. loginPending already protected loginEnabled from that race; the status line has its own hold now. Also two checklist items asserted behaviour the code contradicts: launching a fully-started app from Finder is a reopen, not a hand-off, so it never reaches the instance lock and correctly opens the window in every "Open minimized" mode — the old text expected "Always" to suppress it and would have been filed as a regression. Both are rewritten to test what is reachable, and a duplicated sentence is removed. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 8 +++ docs/contribute/testing.md | 37 +++++++------ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 55 ++++++++++++++----- .../Sources/DezhbanMenu/SettingsView.swift | 20 ++++++- 4 files changed, 89 insertions(+), 31 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 4ac02cb..183660b 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -269,6 +269,14 @@ with an argument, the pre-`SMAppService` pattern. Settings toggle tells the truth about whether anything starts the app at login, and switching it off retracts *both*. + `enable()` refuses while *any* legacy registration survives, not merely an + enabled one. Guarding on enablement looked like a way to keep the advice below + from being a dead end — a `.requiresApproval` item launches nothing, so it cannot + be half of "two launches at login" — but `AssociatedBundleIdentifiers` makes one + "Dezhban" row in Login Items govern both registrations, so approving that row arms + both. The dead end is real and is stated rather than engineered around: a defect + this branch exists to remove cannot be traded for a better error message. + If macOS keeps refusing to retract it, the app has no way out on its own, and it must not pretend otherwise: "toggle it off and on again" was the first advice here and it was unreachable. The control was a `toggle()` that derived diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 43ca278..641144f 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -785,14 +785,15 @@ task gui:build && open dist/Dezhban.app that the instance lock works: exactly **one** menubar item and one Dock tile afterwards, and `pgrep -x DezhbanMenu | wc -l` is 1. Repeat immediately after an upgrade that runs the migration. -- [ ] **A user launch that loses the lock is not a silent no-op.** With the app - running from a `--background` login launch (so it has no window), launch it - again from Finder. The second copy must exit *and* the first must come - forward with its window open — that is the distributed notification in - `acquireSessionOwnership()`. Doing nothing at all here is a worse bug than - the duplicate icon this check's predecessor covers. Then set "Open - minimized" to **Always** and repeat: the first copy must come forward with - **no** window, because always means always. +- [ ] **Launching a fully-started app again just reopens its window.** With the + app already running from a `--background` login launch, launch it from + Finder. LaunchServices will not start a second copy of a running bundle, so + this never reaches the instance lock — it is + `applicationShouldHandleReopen`, which opens the window in **every** "Open + minimized" mode, on purpose: the preference governs the launch, and must + never make the window unreachable. Do not expect "Always" to suppress it + here; the hand-off path is exercised by the startup-race check below, which + is the only way to reach it. - [ ] **The first logout after upgrading may open the window once — and only once.** Expected, not a regression: [ADR-0014](../adr/0014-login-item-launch-marker.md) records why @@ -808,10 +809,15 @@ task gui:build && open dist/Dezhban.app login" there is no window. This is `NSApp.disableRelaunchOnLogin()`; without it, LaunchServices relaunches the app with no arguments and races the agent for the lock. -- [ ] **Two hand-offs in quick succession both open the window.** The debounce - must not swallow real work: with the app running from a `--background` login - launch, double-click it (window opens), ⌘W to close, and double-click again - within a second or two. The window must open **both** times. +- [ ] **Two hand-offs in quick succession both open the window.** Only reachable + while the incumbent is still starting — once it is up, LaunchServices reopens + rather than launching a duplicate, so there is no hand-off to debounce. Log + out and back in, then double-click the app twice in quick succession as early + as you can, closing the window (⌘W) in between. Both must open. Best-effort + by nature: the deterministic coverage is + `HandoffRequestTests.anOverlappingClaimerIsToldItLost` and the `definite` + split in `openForHandoff`, which is what stops the debounce swallowing a real + second request. - [ ] **A hand-off that arrives before the app is observing still works.** The race the `HandoffRequest` file exists for: log out and back in and double-click the app in `/Applications` as early as you can, while it is @@ -819,10 +825,9 @@ task gui:build && open dist/Dezhban.app half a second if the notification missed it, from the bounded backstop that runs for the first few seconds. This must hold on a *slow* login too: the request is honoured however long the incumbent took to finish starting, - because the file can only have been written after it took the lock. This must hold on a *slow* login too: the - request is honoured however long the incumbent took to finish starting, - because the file can only have been written after it took the lock. It must open **once**: no second activation - a moment later, and a window you close right after must stay closed. Then + because the file can only have been written after it took the lock. It must + open **once**: no second activation a moment later, and a window you close + right after must stay closed. Then confirm no `.handoff` file is left in `~/Library/Application Support/com.behnam-rk.dezhban.app/`. - [ ] **A copy run from outside /Applications does not migrate the login item.** diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 68e56c9..36ff36b 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -106,9 +106,9 @@ enum LoginItem { return "macOS is holding this for your approval — enable Dezhban in " + "System Settings → General → Login Items." case .legacyStuck: - return "macOS would not remove the old login item, so Dezhban will still open " - + "at login. Remove \"Dezhban\" under System Settings → General → Login " - + "Items, then switch this on again to use the new one." + return "An old login-item registration is still on file and macOS will not " + + "remove it, so switching this on could start Dezhban twice at login. " + + "Logging out and back in usually clears it." case .agentStuck: return "macOS would not remove the login item, so Dezhban will still open at " + "login. Remove \"Dezhban\" under System Settings → General → Login Items." @@ -237,16 +237,25 @@ enum LoginItem { // `disable()` and the migration both refuse it; this refused nothing, and // the stuck-migration path led straight here: switch reads ON, user clicks // it off, clicks it on again, and both are registered. - // Retract any legacy registration, but refuse only if one is still - // *enabled*. The justification for refusing is "two launches at login, - // one with the marker and one without" — and a `.requiresApproval` legacy - // item launches nothing, so refusing on mere presence was stricter than - // its own reason. It also made `Outcome.legacyStuck`'s advice a dead end: - // removing the item under System Settings leaves `mainApp` at - // `.requiresApproval`, so "then switch this on again" hit the same refusal - // and the agent could never be registered. + // Retract any legacy registration, and refuse while one survives at all — + // not merely while one is *enabled*. + // + // Guarding on enablement was an attempt to keep `.legacyStuck`'s advice + // from being a dead end, on the reasoning that a `.requiresApproval` legacy + // item launches nothing so cannot be half of "two launches at login". But + // `AssociatedBundleIdentifiers` makes ONE "Dezhban" row in Login Items + // govern both registrations, so approving that row arms both — and then the + // agent and the legacy item both start the app, one with the marker and one + // without, racing the instance lock to decide whether the window opens. + // That is the defect this whole branch exists to remove, so it cannot be + // traded for a better error message. + // + // The dead end is real and is now stated honestly instead of being + // engineered around: if macOS will not retract the old registration, + // nothing the app can do will make enabling this safe. See + // `Outcome.legacyStuck`. retractLegacy() - if legacyEnabled { return .legacyStuck } + if registered(.mainApp) { return .legacyStuck } UserDefaults.standard.set(false, forKey: userDisabledKey) do { try service.register() @@ -259,7 +268,17 @@ enum LoginItem { // starting at login. `kSMErrorAlreadyRegistered` is the obvious way in — // the switch stale-OFF, the user clicks it on, and the register throws // precisely because it is already registered. - return registered(service) ? .agentStuck : .failed(error.localizedDescription) + // + // And the outcome comes from the status, not from the fact that a throw + // happened. Mapping every throw-with-a-registration to `.agentStuck` + // told a user who had just asked to turn login-at-launch ON to go and + // remove the login item — the opposite of what they wanted, in the case + // the comment above names as the main way here. + switch service.status { + case .enabled: return .enabled + case .requiresApproval: return .awaitingApproval + default: return .failed(error.localizedDescription) + } } // Checked, not assumed: `register()` returns without throwing when macOS // is going to make the user approve it, and the switch snapping back with @@ -444,6 +463,16 @@ enum LoginItem { private static func markMigrated() { UserDefaults.standard.set(true, forKey: migratedKey) + // Flushed, because `legacyRetractedKey` is. Those two flags are read + // together and one outliving the other inverts the decision they encode: + // this runs seconds into a login, and if the session ended before cfprefsd + // wrote it, a legacy item retracted for a user who had login-at-launch OFF + // left `legacyRetractedKey` durable and `migratedKey` gone — so the next + // launch fell through to `register()` and turned it back on, which ADR-0014 + // says must never happen. The mirror loss strands the account with nothing + // starting the app at login. `disable()` already flushes before the call + // that can end the process; this path has the same obligation. + UserDefaults.standard.synchronize() } /// Whether this bundle lives somewhere it is going to stay. diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 811660b..3d2cb22 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -37,6 +37,16 @@ struct SettingsView: View { /// while a mutation is outstanding, no read may write the switch, whichever /// order the two happen to complete in. @State private var loginPending = false + /// Until when `seed()` must leave the status line alone. + /// + /// `seed()` runs on every `didBecomeActiveNotification`, and macOS delivers one + /// *during* a login-item change because it surfaces System Settings or an + /// approval prompt. So the user came back from that prompt and `seed()` promptly + /// overwrote the line with "Loading…" and then "Seeded from …" — swallowing the + /// `awaitingApproval` and `legacyStuck` guidance, which is the entire reason + /// `Outcome` carries a message. `loginPending` already protects `loginEnabled` + /// from this same race; the status line needed its own. + @State private var loginStatusHoldUntil: Date? @State private var notifyPrefs = NotificationManager.prefs @State private var checkUpdatesEnabled = true @State private var launchVisibility: LaunchVisibility = .bootOnly @@ -796,6 +806,11 @@ struct SettingsView: View { } } + /// Whether a login-item message is still owed the user's attention. + private var holdingLoginStatus: Bool { + loginPending || (loginStatusHoldUntil.map { $0 > Date() } ?? false) + } + private var loginBinding: Binding { Binding( get: { loginEnabled }, @@ -837,6 +852,7 @@ struct SettingsView: View { // claimed it since — otherwise a login result overwrites, say, // "Installing service…" while that install is still running. if status == inProgress { status = outcome.message } + loginStatusHoldUntil = Date().addingTimeInterval(10) } }) } @@ -960,7 +976,7 @@ struct SettingsView: View { // stale `true` is also what keeps the control enabled. tokenEnrolled = ControlToken.isStored refreshTokenCapability() - status = "Loading…" + if !holdingLoginStatus { status = "Loading…" } canApply = false // Off-main for the same reason `set(enabled:)` is: this is two blocking // SMAppService status reads over XPC (it was one before the agent), and @@ -1005,7 +1021,7 @@ struct SettingsView: View { // seeded snapshot are the same thing at this instant and the pane // starts out clean. seededValues = fields.currentValues - status = "Seeded from \(path)" + if !holdingLoginStatus { status = "Seeded from \(path)" } canApply = true } } From c283fccaf5d0b8291173bcf9e18fe8d5dbb32ec0 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 02:44:58 +0330 Subject: [PATCH 16/36] fix(gui): split the two stuck states, which are opposite facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifteenth review round, three findings. legacyStuck was returned from both directions while carrying isOn == true, and the two directions are opposites. From disable() the old item is still enabled, so something IS starting the app and the switch must stay on. From enable() nothing is registered at all — enable() is only entered when the switch read off — so an on-ish outcome snapped the switch ON over a state where nothing was registered, and the next seed() flipped it back. That switch-versus-isEnabled disagreement is the one thing isEnabled's own docstring says must never exist. Sharing the case also had it telling a user who clicked OFF what switching it ON would do. enable() returns blockedByLegacy now, isOn == false, worded for the click that was made. The uninstaller skipped its entire per-user teardown when there is no non-root console user — run at the login window, or over ssh on a Mac nobody is logged into — and every LOGIN_ITEM_STUCK assignment lived inside that block, so it printed an unqualified "files deleted" over an agent registration that survives, an entry that fails to load at every subsequent login, and the migration flag that makes a later reinstall skip the migration. The same silent-clean-report the other states were added to end. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 13 +++++++- docs/contribute/testing.md | 6 ++++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 32 +++++++++++++++---- packaging/macos/uninstall.sh | 16 ++++++++++ 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 183660b..ce3b92e 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -219,7 +219,9 @@ with an argument, the pre-`SMAppService` pattern. it is not a second copy competing for the session — and `packaging/macos/uninstall.sh` runs it as the console user inside their GUI session before deleting the bundle. Root cannot reach another account's launchd - session, so other users' entries are named in the closing message instead. + session, so other users' entries are named in the closing message instead — as is + the case where there is no logged-in user at all (run at the login window, or + over ssh), where none of the per-user teardown can happen. The errand's exit status is load-bearing: `unregister()` only logs a refusal and the script discards the output, so without it a login item macOS would not @@ -269,6 +271,15 @@ with an argument, the pre-`SMAppService` pattern. Settings toggle tells the truth about whether anything starts the app at login, and switching it off retracts *both*. + The two stuck states are separate outcomes, because they are opposite facts. + From the disable direction the old item is still *enabled*, so something is + starting the app and the switch must stay on. From the enable direction nothing + is registered at all — `enable()` is only entered when the switch read off — so + an on-ish outcome snapped the switch on over a state where nothing was + registered, and the next `seed()` flipped it back: the switch-versus-`isEnabled` + disagreement that must never exist. Sharing one case also had it telling a user + who clicked *off* what switching it *on* would do. + `enable()` refuses while *any* legacy registration survives, not merely an enabled one. Guarding on enablement looked like a way to keep the advice below from being a dead end — a `.requiresApproval` item launches nothing, so it cannot diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 641144f..d5c25e0 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -879,6 +879,12 @@ task gui:build && open dist/Dezhban.app `com.behnam-rk.dezhban.app.login` (that is `AssociatedBundleIdentifiers` doing its job — this is the switch a user reaches for to stop the app starting at login, and it is useless if nobody can tell what it governs). +- [ ] **Uninstall with nobody logged in says so.** From an ssh session on a Mac + sitting at the login window, run the uninstaller. It must finish *and* warn + that the per-user leftovers could not be removed — every step of that + teardown needs the user's own launchd session, and reporting a clean removal + would hide both a surviving login item and the migration flag that makes a + later reinstall skip the migration. - [ ] **Uninstall over an already-trashed app says so.** Drag `/Applications/Dezhban.app` to the Trash, then run the uninstaller. It must finish *and* print the warning naming System Settings — only the app can diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 36ff36b..f87760d 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -72,10 +72,21 @@ enum LoginItem { /// Registered, but macOS is holding it for the user's approval — most /// often because they switched this app off in System Settings before. case awaitingApproval - /// The legacy LaunchServices login item is still live and macOS refuses - /// to retract it, so the app still starts at login without the launch - /// marker. Only the user can clear this, in System Settings. + /// The legacy LaunchServices login item is still **enabled** and macOS + /// refuses to retract it, so the app still starts at login without the + /// launch marker. Reached only from the disable direction. case legacyStuck + /// Login-at-launch could not be turned on: an old registration survives + /// that macOS will not remove, and registering the agent beside it would + /// arm two launches at login. Nothing starts the app, so `isOn` is false. + /// + /// Its own case rather than `legacyStuck`, which has `isOn == true`. + /// `enable()` is only entered when the switch read OFF — meaning + /// `isEnabled` was false — so returning an on-ish outcome snapped the + /// switch ON over a state where *nothing* was registered, and the next + /// `seed()` flipped it back. That switch-versus-`isEnabled` disagreement is + /// the one thing `isEnabled`'s docstring says must never exist. + case blockedByLegacy /// The **agent** registration survived an unregister that failed, so the /// app still starts at login. /// @@ -93,7 +104,7 @@ enum LoginItem { var isOn: Bool { switch self { case .enabled, .awaitingApproval, .legacyStuck, .agentStuck: return true - case .disabled, .failed: return false + case .disabled, .failed, .blockedByLegacy: return false } } @@ -106,9 +117,16 @@ enum LoginItem { return "macOS is holding this for your approval — enable Dezhban in " + "System Settings → General → Login Items." case .legacyStuck: + // The disable direction: the old item is still enabled, so it is + // still starting the app. Worded for the click the user made — the + // shared wording described switching it *on*, which is not what + // they did. + return "macOS would not remove the old login item, so Dezhban will still open " + + "at login. Logging out and back in usually clears it." + case .blockedByLegacy: return "An old login-item registration is still on file and macOS will not " - + "remove it, so switching this on could start Dezhban twice at login. " - + "Logging out and back in usually clears it." + + "remove it. Switching this on could start Dezhban twice at login, so it " + + "has been left off. Logging out and back in usually clears it." case .agentStuck: return "macOS would not remove the login item, so Dezhban will still open at " + "login. Remove \"Dezhban\" under System Settings → General → Login Items." @@ -255,7 +273,7 @@ enum LoginItem { // nothing the app can do will make enabling this safe. See // `Outcome.legacyStuck`. retractLegacy() - if registered(.mainApp) { return .legacyStuck } + if registered(.mainApp) { return .blockedByLegacy } UserDefaults.standard.set(false, forKey: userDisabledKey) do { try service.register() diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index fbace8d..904380c 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -189,6 +189,16 @@ if [ -n "$CONSOLE_UID" ]; then # that user rather than by unlinking the plist under them. launchctl asuser "$CONSOLE_UID" sudo -u "$CONSOLE_USER" \ defaults delete "$APP_BUNDLE_ID" >/dev/null 2>&1 || true +else + # No non-root console user: run at the login window, or over ssh on a Mac + # nobody is logged into. Every step above needs that user's own launchd + # session, so the whole per-user teardown is skipped — and because each + # LOGIN_ITEM_STUCK assignment lives inside that block, the script used to print + # an unqualified "files deleted" over an agent registration that survives, an + # entry that fails to load at every subsequent login, and a migration flag that + # makes a LATER reinstall skip the migration. The same silent-clean-report the + # other states were introduced to end. + LOGIN_ITEM_STUCK=no-console-user fi rm -rf "$APP" @@ -233,6 +243,12 @@ none) ;; echo "warning: the app bundle was already gone, so its login item could not" echo " be retracted — only the app itself can do that." ;; + no-console-user) + echo "warning: nobody is logged in, so Dezhban's per-user leftovers could not" + echo " be removed — its login item, and the saved preferences that" + echo " would make a later reinstall skip the login-item migration." + echo " Re-run this uninstaller from a logged-in graphical session." + ;; esac echo " Nothing will start Dezhban (the app is gone). If a \"Dezhban\"" echo " entry remains under System Settings > General > Login Items," From af418dd2082f3d55549a31aafd837cca5b5b4e9d Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 03:08:47 +0330 Subject: [PATCH 17/36] fix(gui): stop gating the hand-off on a preference the reopen path ignores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteenth review round, five findings, and the medium reverts something I added in round nine. The hand-off was gated on "Open minimized", so under Always a double-click that lost the instance lock posted nothing and the launch was a visible no-op. The gate was added on the reasoning that Always must mean always — but the preference governs the LAUNCH, and a user-initiated launch of an already-running app is not one. A second later the identical gesture opens the window, because LaunchServices turns it into a reopen and applicationShouldHandleReopen answers unconditionally in every mode, by design and by documentation. So the gate gave one gesture opposite answers depending on whether the incumbent had finished starting yet, which is worse than either behaviour on its own, and it cost the single launch that had no other route to a window. claim() mapped every removeItem failure to .lost, so a permanent failure — an unwritable Application Support directory with a request file in it — was indistinguishable from the benign race. Every claimer stood down forever, discard() could not clear it either, and the hand-off was dead for every future launch with nothing said. ENOENT is the race; anything else is .blocked, which callers still stand down on (acting on a file that cannot be removed would repeat on every check) but now log. post() swallowed its write error, and the loser then exited assuming one of the two signals would land. If the write fails during the gap the file exists to cover, neither does. It returns a Result now; the caller cannot repair it but can say so. The .unavailable branch called discard() without having taken the lock, against that method's stated contract — so a transient open() failure in a third launch deleted a request the real session owner was about to claim. And the status-line hold was taken even when the message it protects had been declined, suppressing seed()'s updates for ten seconds to defend a line that was never shown. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 13 +++-- .../Sources/DezhbanCore/HandoffRequest.swift | 47 +++++++++++++++---- .../Sources/DezhbanMenu/AppDelegate.swift | 15 +++++- .../Sources/DezhbanMenu/SettingsView.swift | 10 +++- gui/macos/Sources/DezhbanMenu/main.swift | 47 ++++++++++++------- .../HandoffRequestTests.swift | 19 ++++++++ 6 files changed, 118 insertions(+), 33 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index ce3b92e..ff8e1c8 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -129,9 +129,16 @@ with an argument, the pre-`SMAppService` pattern. A launch the *user* performed must never become a silent no-op, so the copy that loses the lock focuses the winner and — when this launch would have opened a window at all — posts a distributed notification asking it to open its own, - since the incumbent may be a `--background` login launch with none. Gated on - the preference, because "Open minimized: Always" has to mean always: otherwise - a second launch of the same app becomes the one way to make a window appear. + since the incumbent may be a `--background` login launch with none. + + Not gated on "Open minimized". It was, on the reasoning that "Always" has to mean + always — but the preference governs the *launch*, and a user-initiated launch of + an already-running app is not one: once the incumbent has finished starting, + LaunchServices turns the same double-click into a reopen, which + `applicationShouldHandleReopen` answers unconditionally in every mode, by design. + So the gate bought no consistency — it gave one gesture opposite answers + depending on whether the incumbent had finished starting — and cost the single + launch with no other route to a window. Scoped by posting the bundle **path** as the notification object, since the name derives from the bundle id and two installs may legitimately run side by side. And a notification rather than re-opening the bundle through diff --git a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift index a618fc8..dd53832 100644 --- a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift +++ b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift @@ -36,10 +36,22 @@ public struct HandoffRequest { HandoffRequest(url: lock.deletingPathExtension().appendingPathExtension("handoff")) } - /// Records a request. Best effort: it is the notification's backstop, and a - /// failure to write it must never stop the losing process from exiting. - public func post() { - try? Data().write(to: url, options: .atomic) + /// Records a request, reporting whether it landed. + /// + /// Still best effort — a failure must never stop the losing process from + /// exiting — but not silent. If this fails while the incumbent is between + /// taking the lock and installing its observer, which is the exact gap the file + /// exists to cover, then neither signal arrives and the user's launch is the + /// silent no-op the mechanism was written to prevent. The caller cannot repair + /// that, but it can say so. + @discardableResult + public func post() -> Result { + do { + try Data().write(to: url, options: .atomic) + return .success(()) + } catch { + return .failure(error) + } } /// The result of trying to take a request. @@ -61,6 +73,16 @@ public struct HandoffRequest { /// There was a request, and somebody else took it first. Whoever did is /// acting on it, so this caller must not. case lost + /// There was a request and it could not be removed for a reason other than + /// somebody else having taken it — an unwritable directory, most likely. + /// + /// Its own case because folding it into `.lost` made a *permanent* failure + /// indistinguishable from a benign race: every claimer forever reported + /// "somebody else has this", every one stood down, `discard()` could not + /// clear it either, and the hand-off mechanism was dead for every future + /// launch with nothing said. Callers still stand down — acting on a file + /// that cannot be removed would repeat on every check — but they log it. + case blocked(String) } /// Removes any request without acting on it. @@ -92,11 +114,18 @@ public struct HandoffRequest { interleaved() do { try FileManager.default.removeItem(at: url) - } catch { - // Gone between the check and the remove: somebody else claimed it. (A - // genuine permissions failure lands here too, and standing down is the - // safe reading of it — a window that does not open, never two.) - return .lost + } catch let error as NSError { + // Gone between the check and the remove is the benign case: somebody + // else claimed it and is acting on it. Anything else is a real failure + // and must not wear the same face — see `Claim.blocked`. + if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { + return .lost + } + if let posix = error.underlyingErrors.first as NSError?, + posix.domain == NSPOSIXErrorDomain, posix.code == Int(ENOENT) { + return .lost + } + return .blocked(error.localizedDescription) } return .fresh } diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 19d2e36..169aa8f 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -172,6 +172,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { case .lost: // The backstop got there first and is opening the window. break + case .blocked(let why): + // Not a race — the request cannot be removed, so acting on it would + // repeat on every check. Logged because this is permanent: the + // hand-off is dead for every future launch until it is fixed. + NSLog("DezhbanMenu: hand-off request could not be claimed: \(why)") } } } @@ -236,7 +241,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Only `.fresh`. `.lost` means the notification handler claimed it and // is already opening the window; `.absent` is the ordinary case of // there being no request at all, which is what almost every tick sees. - guard handoff.claim() == .fresh else { return } + switch handoff.claim() { + case .fresh: + break + case .blocked(let why): + NSLog("DezhbanMenu: hand-off request could not be claimed: \(why)") + return + case .absent, .lost: + return + } DispatchQueue.main.async { self?.openForHandoff(definite: true) } } } diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 3d2cb22..9fe423a 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -851,8 +851,14 @@ struct SettingsView: View { // seconds later, so it is only written if nothing else has // claimed it since — otherwise a login result overwrites, say, // "Installing service…" while that install is still running. - if status == inProgress { status = outcome.message } - loginStatusHoldUntil = Date().addingTimeInterval(10) + // The hold exists to protect a message that is on screen, so + // it is only taken when one was actually written. Setting it + // unconditionally suppressed seed()'s status updates for ten + // seconds to defend a line that had been declined. + if status == inProgress { + status = outcome.message + loginStatusHoldUntil = Date().addingTimeInterval(10) + } } }) } diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index dba61ab..8d9288a 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -114,7 +114,11 @@ func acquireSessionOwnership() -> InstanceLock? { // Never refuse to start over this. A duplicate icon is a smaller failure // than an app that will not launch because a support directory is broken. NSLog("DezhbanMenu: instance lock unavailable, starting anyway: \(why)") - sessionHandoff?.discard() + // No discard here. `discard()` is for a process that has just *taken* the + // lock, on the grounds that anything on disk was meant for a predecessor — + // and this process took nothing. A transient open() failure in a third + // launch would otherwise delete a request the real session owner was about + // to claim, losing that user's double-click. return lock case .heldByAnother: // A background launch loses silently — that copy was never going to show @@ -131,11 +135,19 @@ func acquireSessionOwnership() -> InstanceLock? { } incumbent?.activate() // Ask it to open its window — which it may not currently have, since - // the incumbent may be a --background login launch — but only when - // this launch would have opened one itself. "Open minimized: Always" - // means always: a second launch of the same app must not become the - // one way to make a window appear, or the setting means one thing on - // the first launch and the opposite on the second. + // the incumbent may be a --background login launch. + // + // NOT gated on "Open minimized". It was, on the reasoning that + // "Always" has to mean always — but the preference governs the + // *launch*, and a user-initiated launch of an already-running app is + // not one: once the incumbent has finished starting, LaunchServices + // turns the same double-click into a reopen, and + // `applicationShouldHandleReopen` opens the window unconditionally in + // every mode, by design ("the Dock icon and Open Dezhban… open the + // window regardless"). So gating here bought no consistency at all — + // it gave the same gesture opposite answers depending on whether the + // incumbent happened to have finished starting yet — and cost the one + // launch that had no other route to a window. // // A notification rather than re-opening the bundle through // NSWorkspace: asking LaunchServices to open the app we are in the @@ -147,19 +159,18 @@ func acquireSessionOwnership() -> InstanceLock? { // dist/Dezhban.app run beside an installed copy — an unscoped // notification would have a duplicate launch of one install open the // other install's window. - if LaunchPreference.current.opensWindow(backgroundLaunch: false) { - // The file first, then the notification. The notification is the - // fast path but is never queued, and the incumbent may still be - // starting up with no observer installed — the file is the one - // that waits, and the incumbent's launch-time backstop finds it. - // Whichever of the two gets there claims it, so the window opens - // once (see HandoffRequest). - sessionHandoff?.post() - DistributedNotificationCenter.default().postNotificationName( - NSNotification.Name(AppDelegate.openWindowNotification), - object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path, - userInfo: nil, deliverImmediately: true) + // The file first, then the notification. The notification is the fast + // path but is never queued, and the incumbent may still be starting up + // with no observer installed — the file is the one that waits, and the + // incumbent's launch-time backstop finds it. Whichever of the two gets + // there claims it, so the window opens once (see HandoffRequest). + if case .failure(let error) = sessionHandoff?.post() ?? .success(()) { + NSLog("DezhbanMenu: could not record the hand-off request: \(error)") } + DistributedNotificationCenter.default().postNotificationName( + NSNotification.Name(AppDelegate.openWindowNotification), + object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path, + userInfo: nil, deliverImmediately: true) } NSLog("DezhbanMenu: another copy of this install owns the session; exiting") exit(0) diff --git a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift index ced0679..61ed166 100644 --- a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift @@ -61,6 +61,25 @@ struct HandoffRequestTests { #expect(claim == .lost) } + /// A request that cannot be removed must not look like one somebody else took. + /// Folded together, a permanent failure made every claimer stand down forever + /// and killed the mechanism silently. + @Test func anUnremovableRequestIsBlockedNotLost() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let request = HandoffRequest(url: dir.appendingPathComponent("h.handoff")) + request.post() + + // Read-only parent: the file is visible but cannot be unlinked. + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: dir.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path) } + + guard case .blocked = request.claim() else { + Issue.record("expected .blocked for an unremovable request") + return + } + } + /// Scoped per install, like the lock it sits beside — two installs may /// legitimately run side by side. @Test func theRequestSitsBesideItsOwnLock() { From 8813abf01e6ce1080536f86b537d047c2c0478d0 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 03:34:18 +0330 Subject: [PATCH 18/36] fix: parse dscl as a plist, and accept subfolders of /Applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeenth review round, four findings, all low. The home-directory lookup scraped dscl's plain output with sed, which puts a value containing a space on a continuation line — so for a home like "/Volumes/Home Dirs/jsmith" it came back empty, the guard skipped the cleanup, and the script printed an unqualified "files deleted" over a surviving instance lock. That is the untruth the /Search fix two rounds ago was written to end, and a space is exactly what the network and relocated homes it exists for tend to have. Read as a plist now, verified on this machine. isInStableInstallLocation compared only the immediate parent, so /Applications/Utilities/Dezhban.app never migrated — an ordinary thing for someone to do with an app, and certainly a location it stays in. That user's legacy item kept starting the app with no marker and "Open minimized" stayed broken for them permanently, reported only in a log line. Anywhere under an Applications directory counts now. The status line had one writer left unguarded. The login completion checks before writing and seed() respects the hold, but the service toggle's completion cleared unconditionally — and the login toggle is not disabled during that sequence, so a user could flip it mid-install, get the awaitingApproval guidance they must act on for the switch not to be lying, and have it wiped a moment later. Also dropped a leftover clause in ADR-0014 that still described the hand-off as gated on "Open minimized", one paragraph above the text explaining that it is not. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 6 +++--- docs/contribute/testing.md | 6 ++++++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 20 ++++++++++++------- .../Sources/DezhbanMenu/SettingsView.swift | 6 +++++- packaging/macos/uninstall.sh | 10 ++++++++-- 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index ff8e1c8..6175955 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -127,9 +127,9 @@ with an argument, the pre-`SMAppService` pattern. creates a fresh inode, locks *that*, and runs a second copy undetectably. A launch the *user* performed must never become a silent no-op, so the copy - that loses the lock focuses the winner and — when this launch would have opened - a window at all — posts a distributed notification asking it to open its own, - since the incumbent may be a `--background` login launch with none. + that loses the lock focuses the winner and posts a distributed notification + asking it to open its own, since the incumbent may be a `--background` login + launch with none. Not gated on "Open minimized". It was, on the reasoning that "Always" has to mean always — but the preference governs the *launch*, and a user-initiated launch of diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index d5c25e0..38ec615 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -830,6 +830,12 @@ task gui:build && open dist/Dezhban.app right after must stay closed. Then confirm no `.handoff` file is left in `~/Library/Application Support/com.behnam-rk.dezhban.app/`. +- [ ] **An app filed into a subfolder of /Applications still migrates.** Move + `Dezhban.app` into `/Applications/Utilities/`, launch it on a pre-agent + install with login-at-launch on: it must migrate. Anywhere under an + Applications directory counts as a place the app will stay; comparing only the + immediate parent left that user's legacy item running with no marker + permanently, reported nowhere. - [ ] **A copy run from outside /Applications does not migrate the login item.** Unzip `Dezhban-macos.app.zip` to `~/Downloads` on a Mac with a pre-agent install and login-at-launch on, run it once, quit. The legacy login item diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index f87760d..f619831 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -507,16 +507,22 @@ enum LoginItem { /// literal string comparison silently answered "no" and left the migration /// undone forever, reported only in a log line. private static var isInStableInstallLocation: Bool { - let parent = Bundle.main.bundleURL + let bundle = Bundle.main.bundleURL .resolvingSymlinksInPath() .standardizedFileURL .deletingLastPathComponent() - let candidates = [URL(fileURLWithPath: "/Applications")] - + FileManager.default - .urls(for: .applicationDirectory, in: .userDomainMask) - return candidates.contains { - $0.resolvingSymlinksInPath().standardizedFileURL.path == parent.path - } + .path + let roots = ([URL(fileURLWithPath: "/Applications")] + + FileManager.default.urls(for: .applicationDirectory, in: .userDomainMask)) + .map { $0.resolvingSymlinksInPath().standardizedFileURL.path } + // Anywhere *under* an Applications directory, not only directly in one. + // Comparing just the immediate parent excluded + // /Applications/Utilities/Dezhban.app — an ordinary thing for someone to do + // with an app, and certainly a location it is going to stay in. That user + // never migrated, so the legacy item kept starting the app with no marker + // and "Open minimized" stayed broken for them permanently, reported only in + // a log line nobody reads. + return roots.contains { bundle == $0 || bundle.hasPrefix($0 + "/") } } /// Words, not a raw `SMAppService.Status`. It is an imported `NS_ENUM` with no diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 9fe423a..8125bdd 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -799,7 +799,11 @@ struct SettingsView: View { AppActions.capturedSequence(wantInstalled ? AppActions.installCommands : AppActions.uninstallCommands) { result in bootBusy = false - status = "" + // Not while a login-item message is owed. The login toggle is not + // disabled during this sequence, so a user can flip it mid-install, get + // `awaitingApproval` guidance — the one message they must act on for the + // switch not to be lying — and have this clear it a moment later. + if !holdingLoginStatus { status = "" } if !result.ok { state.showInLogs(title: "\(title) — failed", text: result.output) } diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 904380c..614142a 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -176,8 +176,14 @@ if [ -n "$CONSOLE_UID" ]; then # /Search, not the local node. `dscl .` reads only local records, so for a # network/LDAP/AD account it returns nothing — leaving this to skip the delete # for precisely the accounts the lookup exists to serve. - CONSOLE_HOME=$(dscl /Search -read "/Users/$CONSOLE_USER" NFSHomeDirectory 2>/dev/null | - sed -n 's/^NFSHomeDirectory: //p') + # + # And read as a plist, not scraped with sed. `dscl`'s plain output puts a value + # containing a space on a *continuation* line ("NFSHomeDirectory:\n /Volumes/Home + # Dirs/jsmith"), so the obvious `s/^NFSHomeDirectory: //p` came back empty and + # skipped the cleanup — for a home with a space in it, which is exactly what the + # network and relocated homes this lookup exists for tend to have. + CONSOLE_HOME=$(dscl -plist /Search -read "/Users/$CONSOLE_USER" NFSHomeDirectory 2>/dev/null | + plutil -extract 'dsAttrTypeStandard:NFSHomeDirectory.0' raw -o - -- - 2>/dev/null) if [ -n "$CONSOLE_HOME" ] && [ -d "$CONSOLE_HOME" ]; then rm -rf "$CONSOLE_HOME/Library/Application Support/$APP_BUNDLE_ID" fi From f6c20ed95a673c9aefba49695524bd245bc4f0b3 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 04:08:18 +0330 Subject: [PATCH 19/36] fix: look for the bundle the app was allowed to register from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteenth review round, four findings. Last round widened where the migration may run from — anywhere under an Applications directory, so filing the app into /Applications/Utilities is supported — and left the uninstaller looking only at /Applications/Dezhban.app. That install therefore got "the app bundle was already gone", an agent unloaded for this boot only, an rm -rf that deleted nothing, and a bundle that kept launching at every subsequent login, while all three printed messages said it had been removed. The bundle is searched for now, in the same places the app is allowed to live. The label-drift guard used grep without -F, so the pattern is a regex and `.` is a wildcard: a label renamed to "…app-login" in one consumer still matched "…app.login", and the check passed over exactly the drift it exists to catch — leaving SMAppService naming a plist that does not exist, which surfaces only as the .notFound status nobody reads. enable() was the one write to the three coupled login-item flags without a flush, while disable(), retractLegacy() and markMigrated() all have one and say why. Clearing the explicit-off in memory only meant a session that ended first left it reading true, so the next launch marked the account migrated and permanently cancelled the register() retry added so nobody is stranded with nothing starting the app at login. And "instance lock" collided with the glossary's existing Single-instance lock, which is the daemon's lock over the state directory — one name for two unrelated mechanisms, in the file CLAUDE.md makes the authority for a term. The GUI type is SessionLock now, and the glossary gained entries for it and for launch marker, login agent and hand-off request, with the distinction stated. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- docs/adr/0014-login-item-launch-marker.md | 10 +++- docs/concepts/glossary.md | 27 ++++++++++ docs/contribute/testing.md | 10 +++- .../Sources/DezhbanCore/HandoffRequest.swift | 6 +-- .../{InstanceLock.swift => SessionLock.swift} | 6 +-- .../Sources/DezhbanMenu/AppDelegate.swift | 6 +-- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 14 ++++-- gui/macos/Sources/DezhbanMenu/main.swift | 12 ++--- ...LockTests.swift => SessionLockTests.swift} | 34 ++++++------- gui/macos/build-app.sh | 6 ++- packaging/macos/uninstall.sh | 49 +++++++++++++------ 12 files changed, 124 insertions(+), 58 deletions(-) rename gui/macos/Sources/DezhbanCore/{InstanceLock.swift => SessionLock.swift} (97%) rename gui/macos/Tests/DezhbanCoreTests/{InstanceLockTests.swift => SessionLockTests.swift} (87%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 264b114..c86cc7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -496,7 +496,7 @@ current as you land changes. unwarranted repair; it is now treated as an unreadable check, the same discipline already applied to a fully failed query. - **A local, unprivileged process could block the Windows kill switch from - ever starting.** The single-instance lock's mutex lived under a predictable + ever starting.** The single-session lock's mutex lived under a predictable `Global\` name, so anyone could pre-create it first and either get treated as the legitimate "already running" holder or deny the daemon's own `CreateMutexW` with a hostile DACL. It now lives inside a boundary-restricted diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 6175955..de2a46c 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -208,7 +208,7 @@ with an argument, the pre-`SMAppService` pattern. "Reopen windows when logging back in" relaunches whatever was running at logout, through LaunchServices, with no arguments. `SMAppService.mainApp` was reconciled with that path because it went through LaunchServices too; a launchd - agent is not, so both would start at login and race for the instance lock, and + agent is not, so both would start at login and race for the session lock, and a resume copy that won made the window open at login under the default `bootOnly` — this very defect, intermittent instead of absent. `NSApp.disableRelaunchOnLogin()` is the API for "the login item is the only way @@ -222,7 +222,7 @@ with an argument, the pre-`SMAppService` pattern. pointing at a plist inside a bundle that has been deleted, which is exactly the orphan being avoided. `SMAppService.unregister()` is the only real retraction and it can only be called by the app, so `DezhbanMenu` takes a - `--unregister-login-item` errand flag — handled before the instance lock, since + `--unregister-login-item` errand flag — handled before the session lock, since it is not a second copy competing for the session — and `packaging/macos/uninstall.sh` runs it as the console user inside their GUI session before deleting the bundle. Root cannot reach another account's launchd @@ -230,6 +230,12 @@ with an argument, the pre-`SMAppService` pattern. the case where there is no logged-in user at all (run at the login window, or over ssh), where none of the per-user teardown can happen. + The uninstaller also *looks* for the bundle rather than assuming + `/Applications/Dezhban.app`. The migration is allowed to run from anywhere under + an Applications directory, so a copy filed into `/Applications/Utilities` would + otherwise be told its bundle was already gone — while it went on launching at + every login and the script reported everything removed. + The errand's exit status is load-bearing: `unregister()` only logs a refusal and the script discards the output, so without it a login item macOS would not retract stayed behind — pointing at a bundle deleted moments later, unreachable diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md index f9a9c80..614a1bf 100644 --- a/docs/concepts/glossary.md +++ b/docs/concepts/glossary.md @@ -261,6 +261,33 @@ available, root-only, and independent of the socket. root, **with no daemon running**. Deliberately not a socket operation, because the escape hatch must never depend on the thing it is escaping from. +**Launch marker** — the `--background` argument the macOS app's login LaunchAgent +passes and nothing else does, so the app knows macOS started it at login rather +than the user starting it. What the "Open minimized" setting decides on; before +it, the app inferred the launch kind from an AppKit key that read wrong in both +directions ([ADR-0014](../adr/0014-login-item-launch-marker.md)). + +**Login agent** — `Contents/Library/LaunchAgents/com.behnam-rk.dezhban.app.login.plist` +inside `Dezhban.app`, registered with `SMAppService.agent(plistName:)`. It is what +starts the app at login, and it exists in place of `SMAppService.mainApp` solely +because a LaunchAgent can pass the **launch marker**. Unlike the login item it +replaced it does not disappear with the bundle, so `uninstall.sh` has the app +retract it. + +**Session lock** — an exclusive `flock` the **macOS app** holds for its lifetime, +one per install, so a second copy of the same bundle exits at startup instead of +running a second menubar item, Dock tile and state-file timer. Needed because +registering the login agent starts the app immediately and launchd, unlike +LaunchServices, does not care that it is already running. Distinct from the +**single-instance lock** below, which is the daemon's and guards `Backend.Apply`; +this one guards nothing but the app's own uniqueness. + +**Hand-off request** — a file beside the **session lock** by which a copy of the +app that is exiting asks the copy that owns the session to show its window, so a +launch the user performed is never a silent no-op. A file rather than only a +notification because the notification is never queued and the owner may not be +observing yet. + **Single-instance lock** — an exclusive lock `run` holds over the state directory for its entire lifetime, so a second `run` — with or without `--no-daemon` — refuses outright instead of racing the first to call `Backend.Apply`. Released diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 38ec615..e42b424 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -782,13 +782,13 @@ task gui:build && open dist/Dezhban.app app up, switch Settings → "Open this app at login" **off then on**. The agent's `RunAtLoad` execs a second copy the moment it registers, and launchd does not dedupe the way LaunchServices did, so this is the check - that the instance lock works: exactly **one** menubar item and one Dock + that the session lock works: exactly **one** menubar item and one Dock tile afterwards, and `pgrep -x DezhbanMenu | wc -l` is 1. Repeat immediately after an upgrade that runs the migration. - [ ] **Launching a fully-started app again just reopens its window.** With the app already running from a `--background` login launch, launch it from Finder. LaunchServices will not start a second copy of a running bundle, so - this never reaches the instance lock — it is + this never reaches the session lock — it is `applicationShouldHandleReopen`, which opens the window in **every** "Open minimized" mode, on purpose: the preference governs the launch, and must never make the window unreachable. Do not expect "Always" to suppress it @@ -885,6 +885,12 @@ task gui:build && open dist/Dezhban.app `com.behnam-rk.dezhban.app.login` (that is `AssociatedBundleIdentifiers` doing its job — this is the switch a user reaches for to stop the app starting at login, and it is useless if nobody can tell what it governs). +- [ ] **Uninstall finds the app where it actually is.** Move `Dezhban.app` into + `/Applications/Utilities/`, let it register the login agent, then run the + uninstaller. It must locate the bundle there, retract the agent, delete it, + and print **no** "app bundle was already gone" warning — the app is allowed to + register from anywhere under an Applications directory, so the uninstaller has + to look in the same places. - [ ] **Uninstall with nobody logged in says so.** From an ssh session on a Mac sitting at the login window, run the uninstaller. It must finish *and* warn that the per-user leftovers could not be removed — every step of that diff --git a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift index dd53832..37578e7 100644 --- a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift +++ b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift @@ -1,10 +1,10 @@ import Foundation -/// A file beside the instance lock saying "a user tried to launch this app; +/// A file beside the session lock saying "a user tried to launch this app; /// please show yourself". /// /// The distributed notification that normally carries this is delivered -/// immediately and never queued, and the incumbent takes the instance lock +/// immediately and never queued, and the incumbent takes the session lock /// *before* `NSApplication` exists — so between acquiring the lock and installing /// its observer there is a window in which a hand-off is posted to nobody. It is /// short, but it lands exactly at login, when a user impatient with a slow start @@ -31,7 +31,7 @@ public struct HandoffRequest { } /// Derived from the lock's own URL, so it is scoped per install for exactly - /// the reasons the lock is (see `InstanceLock.forBundle`). + /// the reasons the lock is (see `SessionLock.forBundle`). public static func beside(lock: URL) -> HandoffRequest { HandoffRequest(url: lock.deletingPathExtension().appendingPathExtension("handoff")) } diff --git a/gui/macos/Sources/DezhbanCore/InstanceLock.swift b/gui/macos/Sources/DezhbanCore/SessionLock.swift similarity index 97% rename from gui/macos/Sources/DezhbanCore/InstanceLock.swift rename to gui/macos/Sources/DezhbanCore/SessionLock.swift index 2ebf4a4..f480170 100644 --- a/gui/macos/Sources/DezhbanCore/InstanceLock.swift +++ b/gui/macos/Sources/DezhbanCore/SessionLock.swift @@ -31,7 +31,7 @@ import Foundation /// against an installed `/Applications/Dezhban.app` is the documented GUI dev loop /// (docs/contribute/testing.md), and an identifier-scoped lock would have made the /// freshly built copy exit on launch and silently test the installed one instead. -public final class InstanceLock { +public final class SessionLock { public enum Acquisition: Equatable { /// This process now owns the session and holds the lock until it exits. case acquired @@ -77,12 +77,12 @@ public final class InstanceLock { /// same way, for the same reason. public static func forBundle(path bundlePath: String, identifier: String, - supportDirectory: URL) -> InstanceLock { + supportDirectory: URL) -> SessionLock { let dir = supportDirectory.appendingPathComponent(identifier, isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let key = URL(fileURLWithPath: bundlePath).resolvingSymlinksInPath().standardizedFileURL.path let name = "instance-" + String(fnv1a(key), radix: 16) + ".lock" - return InstanceLock(url: dir.appendingPathComponent(name)) + return SessionLock(url: dir.appendingPathComponent(name)) } /// FNV-1a, 64-bit. Deterministic across processes and OS versions, which is diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 169aa8f..6f3eb55 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -39,7 +39,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// Posted by a duplicate copy of the app as it exits, when the user started /// it themselves (see `acquireSessionOwnership` in main.swift). Without it a - /// user-initiated launch that loses the instance lock would do visibly + /// user-initiated launch that loses the session lock would do visibly /// nothing at all — and the copy that owns the session may be a /// `--background` login launch with no window to be handed over to. static let openWindowNotification = "com.behnam-rk.dezhban.app.openWindow" @@ -51,7 +51,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // dropped — the one outcome `acquireSessionOwnership` exists to prevent. // Scoped to the bundle path, which is what the poster sends: the name // comes from the bundle id, and two installs of the app may legitimately - // run side by side (see InstanceLock). + // run side by side (see SessionLock). DistributedNotificationCenter.default().addObserver( self, selector: #selector(openWindowRequested), name: NSNotification.Name(Self.openWindowNotification), @@ -66,7 +66,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // whatever was running at logout, through LaunchServices, with no // arguments. `SMAppService.mainApp` used to be reconciled with that path // because it went through LaunchServices too; a launchd agent is not, so - // both would start at login and race for the instance lock — and if the + // both would start at login and race for the session lock — and if the // resume copy won, the window opened at login under the default "Only at // login", the exact defect this replaced, now intermittent instead of // absent. This is the API for saying "the login item is the only way I diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index f619831..e9600df 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -15,7 +15,7 @@ import ServiceManagement /// Registering an agent `exec`s the app immediately (`RunAtLoad`), so both /// `set(enabled:)` and the migration below can spawn a second copy of a running /// app. -/// That is caught at startup by the instance lock in main.swift, not here — the +/// That is caught at startup by the session lock in main.swift, not here — the /// duplicate is a *process* problem and this type has no way to see it. enum LoginItem { /// Must match `LoginAgent.plist`'s `Label` and the filename build-app.sh @@ -251,7 +251,7 @@ enum LoginItem { private static func enable() -> Outcome { // The agent must never be registered beside a live legacy item — that is // two launches at login, one with the marker and one without, and - // whichever won the instance lock would decide whether the window opened. + // whichever won the session lock would decide whether the window opened. // `disable()` and the migration both refuse it; this refused nothing, and // the stuck-migration path led straight here: switch reads ON, user clicks // it off, clicks it on again, and both are registered. @@ -264,7 +264,7 @@ enum LoginItem { // `AssociatedBundleIdentifiers` makes ONE "Dezhban" row in Login Items // govern both registrations, so approving that row arms both — and then the // agent and the legacy item both start the app, one with the marker and one - // without, racing the instance lock to decide whether the window opens. + // without, racing the session lock to decide whether the window opens. // That is the defect this whole branch exists to remove, so it cannot be // traded for a better error message. // @@ -275,6 +275,12 @@ enum LoginItem { retractLegacy() if registered(.mainApp) { return .blockedByLegacy } UserDefaults.standard.set(false, forKey: userDisabledKey) + // Flushed, like every other write to these three coupled flags. This pane + // can have its process killed by launchd mid-operation, and clearing the + // explicit-off only in memory meant the next launch still read it as true — + // marking the account migrated and permanently cancelling the register() + // retry that exists so nobody is stranded with nothing starting the app. + UserDefaults.standard.synchronize() do { try service.register() } catch { @@ -502,7 +508,7 @@ enum LoginItem { /// the login agent from it would point launchd at a bundle that stops /// existing. /// - /// Symlinks are resolved for the same reason `InstanceLock` resolves them: an + /// Symlinks are resolved for the same reason `SessionLock` resolves them: an /// install reached through a symlinked directory is still that install, and a /// literal string comparison silently answered "no" and left the migration /// undone forever, reported only in a log line. diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index 8d9288a..438fdbb 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -52,7 +52,7 @@ func makeMainMenu() -> NSMenu { return main } -/// The hand-off request beside this install's instance lock, once known. +/// The hand-off request beside this install's session lock, once known. /// /// A global because both ends of the launch need it: `acquireSessionOwnership()` /// writes it from a losing copy, and `AppDelegate` claims it — from the @@ -70,7 +70,7 @@ var sessionHandoff: HandoffRequest? /// app can call it, so `packaging/macos/uninstall.sh` runs the binary this way /// (as the console user, in their GUI session) before deleting anything. /// -/// Handled before the instance lock, deliberately: this is not a second copy of +/// Handled before the session lock, deliberately: this is not a second copy of /// the app competing for the session, it is a one-shot errand, and it must work /// while the app is running — which is exactly when the uninstaller finds it. func retractLoginRegistrationsAndExit() { @@ -90,11 +90,11 @@ func retractLoginRegistrationsAndExit() { /// LaunchServices, so nothing else dedupes it. Without this the user gets two /// menubar items, and the duplicate carries `--background`, so under the default /// "Only at login" it opens no window and there is no way to tell which icon is -/// which. See `InstanceLock` and docs/adr/0014-login-item-launch-marker.md. +/// which. See `SessionLock` and docs/adr/0014-login-item-launch-marker.md. /// /// Returns the lock on success. The caller must keep it alive for the lifetime of /// the process — the lock IS the open file descriptor. -func acquireSessionOwnership() -> InstanceLock? { +func acquireSessionOwnership() -> SessionLock? { // No bundle identifier means a bare `swift run` binary: no agent could have // spawned it, and nothing to scope a lock to. guard let id = Bundle.main.bundleIdentifier, @@ -102,7 +102,7 @@ func acquireSessionOwnership() -> InstanceLock? { .urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } - let lock = InstanceLock.forBundle( + let lock = SessionLock.forBundle( path: Bundle.main.bundleURL.path, identifier: id, supportDirectory: support) sessionHandoff = HandoffRequest.beside(lock: lock.url) switch lock.acquire() { @@ -113,7 +113,7 @@ func acquireSessionOwnership() -> InstanceLock? { case .unavailable(let why): // Never refuse to start over this. A duplicate icon is a smaller failure // than an app that will not launch because a support directory is broken. - NSLog("DezhbanMenu: instance lock unavailable, starting anyway: \(why)") + NSLog("DezhbanMenu: session lock unavailable, starting anyway: \(why)") // No discard here. `discard()` is for a process that has just *taken* the // lock, on the grounds that anything on disk was meant for a predecessor — // and this process took nothing. A transient open() failure in a third diff --git a/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift b/gui/macos/Tests/DezhbanCoreTests/SessionLockTests.swift similarity index 87% rename from gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift rename to gui/macos/Tests/DezhbanCoreTests/SessionLockTests.swift index c9027f3..e1f8e05 100644 --- a/gui/macos/Tests/DezhbanCoreTests/InstanceLockTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/SessionLockTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import DezhbanCore -struct InstanceLockTests { +struct SessionLockTests { private func tempDir() throws -> URL { let dir = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("dezhban-instancelock-\(UUID().uuidString)", isDirectory: true) @@ -19,8 +19,8 @@ struct InstanceLockTests { defer { try? FileManager.default.removeItem(at: dir) } let path = dir.appendingPathComponent("a.lock") - let first = InstanceLock(url: path) - let second = InstanceLock(url: path) + let first = SessionLock(url: path) + let second = SessionLock(url: path) defer { first.release(); second.release() } #expect(first.acquire() == .acquired) @@ -35,7 +35,7 @@ struct InstanceLockTests { defer { try? FileManager.default.removeItem(at: dir) } let path = dir.appendingPathComponent("b.lock") - let contenders = (0 ..< 5).map { _ in InstanceLock(url: path) } + let contenders = (0 ..< 5).map { _ in SessionLock(url: path) } defer { contenders.forEach { $0.release() } } let winners = contenders.filter { $0.acquire() == .acquired } @@ -50,8 +50,8 @@ struct InstanceLockTests { defer { try? FileManager.default.removeItem(at: dir) } let path = dir.appendingPathComponent("c.lock") - let first = InstanceLock(url: path) - let second = InstanceLock(url: path) + let first = SessionLock(url: path) + let second = SessionLock(url: path) defer { second.release() } #expect(first.acquire() == .acquired) @@ -65,7 +65,7 @@ struct InstanceLockTests { @Test func reacquiringTheSameLockIsIdempotent() throws { let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } - let lock = InstanceLock(url: dir.appendingPathComponent("d.lock")) + let lock = SessionLock(url: dir.appendingPathComponent("d.lock")) defer { lock.release() } #expect(lock.acquire() == .acquired) @@ -76,7 +76,7 @@ struct InstanceLockTests { /// support directory is a worse thing to fail a launch on than a duplicate /// icon. @Test func anUnopenableLockPathIsReportedRatherThanBlocking() { - let lock = InstanceLock(url: URL(fileURLWithPath: "/dev/null/nope/e.lock")) + let lock = SessionLock(url: URL(fileURLWithPath: "/dev/null/nope/e.lock")) defer { lock.release() } guard case .unavailable = lock.acquire() else { Issue.record("expected .unavailable for an unopenable path") @@ -91,9 +91,9 @@ struct InstanceLockTests { @Test func differentInstallPathsGetDifferentLocks() throws { let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } - let installed = InstanceLock.forBundle( + let installed = SessionLock.forBundle( path: "/Applications/Dezhban.app", identifier: "com.example.app", supportDirectory: dir) - let built = InstanceLock.forBundle( + let built = SessionLock.forBundle( path: "/Users/x/dev/dezhban/dist/Dezhban.app", identifier: "com.example.app", supportDirectory: dir) defer { installed.release(); built.release() } @@ -124,9 +124,9 @@ struct InstanceLockTests { let link = root.appendingPathComponent("link", isDirectory: true) try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) - let viaReal = InstanceLock.forBundle( + let viaReal = SessionLock.forBundle( path: bundle.path, identifier: "com.example.app", supportDirectory: locks) - let viaLink = InstanceLock.forBundle( + let viaLink = SessionLock.forBundle( path: link.appendingPathComponent("Dezhban.app").path, identifier: "com.example.app", supportDirectory: locks) defer { viaReal.release(); viaLink.release() } @@ -142,15 +142,15 @@ struct InstanceLockTests { @Test func theLockNameIsStableAcrossProcesses() throws { let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } - let a = InstanceLock.forBundle( + let a = SessionLock.forBundle( path: "/Applications/Dezhban.app", identifier: "com.example.app", supportDirectory: dir) - let b = InstanceLock.forBundle( + let b = SessionLock.forBundle( path: "/Applications/Dezhban.app", identifier: "com.example.app", supportDirectory: dir) #expect(a.url == b.url) // FNV-1a of the empty string is its offset basis; a seeded hash would not // reproduce it. - #expect(InstanceLock.fnv1a("") == 0xcbf2_9ce4_8422_2325) - #expect(InstanceLock.fnv1a("a") == InstanceLock.fnv1a("a")) - #expect(InstanceLock.fnv1a("a") != InstanceLock.fnv1a("b")) + #expect(SessionLock.fnv1a("") == 0xcbf2_9ce4_8422_2325) + #expect(SessionLock.fnv1a("a") == SessionLock.fnv1a("a")) + #expect(SessionLock.fnv1a("a") != SessionLock.fnv1a("b")) } } diff --git a/gui/macos/build-app.sh b/gui/macos/build-app.sh index f908437..e94eb00 100755 --- a/gui/macos/build-app.sh +++ b/gui/macos/build-app.sh @@ -117,7 +117,11 @@ fi # here would otherwise satisfy check 1 while SMAppService named a file that # does not exist — reported only as the .notFound status nobody reads. for consumer in "$HERE/Sources/DezhbanMenu/LoginItem.swift" "$REPO_ROOT/packaging/macos/uninstall.sh"; do - if ! grep -q "$AGENT_LABEL" "$consumer"; then + # -F: a fixed string, not a regex. Unanchored, `.` is a wildcard, so a label + # renamed to "…app-login" in one consumer still matched the pattern "…app.login" + # — the drift check passing over exactly the drift it exists to catch, leaving + # SMAppService naming a plist that does not exist. + if ! grep -qF "$AGENT_LABEL" "$consumer"; then echo "build-app.sh: $consumer does not mention '$AGENT_LABEL' — the label, LoginItem.plistName and the uninstaller have drifted apart, and login-at-launch would fail silently" >&2 exit 1 fi diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 614142a..ca06402 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -66,8 +66,39 @@ pkill -x DezhbanMenu >/dev/null 2>&1 || true # console user; other accounts get the one command they need, printed at the end. CONSOLE_USER=$(stat -f %Su /dev/console 2>/dev/null || echo "") CONSOLE_UID="" +CONSOLE_HOME="" if [ -n "$CONSOLE_USER" ] && [ "$CONSOLE_USER" != "root" ]; then CONSOLE_UID=$(id -u "$CONSOLE_USER" 2>/dev/null || echo "") + # /Search, not the local node. `dscl .` reads only local records, so for a + # network/LDAP/AD account it returns nothing — leaving the cleanup below to be + # skipped for precisely the accounts this lookup exists to serve. + # + # And read as a plist, not scraped with sed. `dscl`'s plain output puts a value + # containing a space on a *continuation* line ("NFSHomeDirectory:\n /Volumes/Home + # Dirs/jsmith"), so the obvious `s/^NFSHomeDirectory: //p` came back empty — for + # a home with a space in it, which is what network and relocated homes tend to + # have. + CONSOLE_HOME=$(dscl -plist /Search -read "/Users/$CONSOLE_USER" NFSHomeDirectory 2>/dev/null | + plutil -extract 'dsAttrTypeStandard:NFSHomeDirectory.0' raw -o - -- - 2>/dev/null) +fi + +# The bundle is looked for, not assumed. The app is allowed to register the login +# agent from anywhere under /Applications or ~/Applications (LoginItem's +# isInStableInstallLocation), so filing it into /Applications/Utilities is a +# supported thing to have done — and with APP fixed at /Applications/Dezhban.app +# that install got "the app bundle was already gone", an unloaded-for-this-boot +# agent, an `rm -rf` that deleted nothing, and a bundle that kept launching at +# every subsequent login while the script said everything was removed. +if [ ! -d "$APP" ]; then + for root in /Applications "${CONSOLE_HOME:+$CONSOLE_HOME/Applications}"; do + [ -d "$root" ] || continue + found=$(find "$root" -maxdepth 3 -name Dezhban.app -type d -print 2>/dev/null | head -1) + if [ -n "$found" ]; then + APP="$found" + echo "note: found the app at $APP" >&2 + break + fi + done fi if [ -n "$CONSOLE_UID" ]; then echo "unregistering the login agent for $CONSOLE_USER ..." @@ -163,27 +194,13 @@ if [ -n "$CONSOLE_UID" ]; then wait "$errand" >/dev/null 2>&1 || true fi launchctl bootout "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1 || true - # The app's own per-user directory: the instance lock the GUI takes at startup + # The app's own per-user directory: the session lock the GUI takes at startup # to keep a second copy of itself from running, and the hand-off file beside # it. Machine-derived, none of it the user's — and files this version creates # that no earlier one did, so leaving them would make this script's own # promise false. # - # The home directory is asked for, not assumed: a network or mobile account, - # or a relocated home, is not under /Users, and hardcoding that path made the - # closing "files deleted" line untrue for exactly those users. - # - # /Search, not the local node. `dscl .` reads only local records, so for a - # network/LDAP/AD account it returns nothing — leaving this to skip the delete - # for precisely the accounts the lookup exists to serve. - # - # And read as a plist, not scraped with sed. `dscl`'s plain output puts a value - # containing a space on a *continuation* line ("NFSHomeDirectory:\n /Volumes/Home - # Dirs/jsmith"), so the obvious `s/^NFSHomeDirectory: //p` came back empty and - # skipped the cleanup — for a home with a space in it, which is exactly what the - # network and relocated homes this lookup exists for tend to have. - CONSOLE_HOME=$(dscl -plist /Search -read "/Users/$CONSOLE_USER" NFSHomeDirectory 2>/dev/null | - plutil -extract 'dsAttrTypeStandard:NFSHomeDirectory.0' raw -o - -- - 2>/dev/null) + # CONSOLE_HOME is resolved up top, where the bundle search needs it too. if [ -n "$CONSOLE_HOME" ] && [ -d "$CONSOLE_HOME" ]; then rm -rf "$CONSOLE_HOME/Library/Application Support/$APP_BUNDLE_ID" fi From e54fe2741fa3ef01f584ab43c501bac95705fd3b Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 04:34:13 +0330 Subject: [PATCH 20/36] fix: undo a rename that reached a shipped changelog entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nineteenth review round, five findings, and the medium is collateral from last round's rename. A blanket substitution turned "the single-instance lock's mutex" in a released CHANGELOG entry into "single-session lock" — the daemon's Windows mutex, which is called single-instance everywhere else in the tree including the glossary entry this branch just added, which points the reader at it by that name. `grep -rn single-session` matched exactly one line in the repo: the one I had broken. Restored. blockedByLegacy was returned whenever any legacy registration survived a failed retraction, with isOn false — but when the survivor is *enabled* the app is still starting at login, so the switch snapped OFF over a live login launch and the next seed() flipped it back. The argument for why that was impossible ("enable() is only entered when the switch read OFF, so isEnabled was false") assumes a fresh switch, and the rest of this code deliberately treats it as stale — which is why set(enabled:) takes the state the user asked for instead of re-reading. The outcome comes from what actually survived now. The login toggle and the service toggle were competing for one status line, and guarding one direction only moved the loss to the other: the in-progress write was unguarded, so flipping the login toggle mid-install destroyed the install's progress message, and the install's completion then declined to clear it. They are two facts with different lifetimes, so the login item has its own line under the toggle and the hold machinery is gone. Requiring a hand-off file outside the launch window — which is what keeps an unauthenticated notification from being an activate-on-demand channel — made a failed post() a visible no-op, reachable on a full or read-only home. The poster is the only party that knows its file did not land, so it says so in the notification rather than the receiver inferring it from a timer. The exemption weakens nothing already reachable: anything able to forge that notification could equally run `open -a Dezhban`. And the .blocked test manufactured its condition with a 0o500 directory, which root ignores — so it would have failed under sudo for a reason unrelated to the code, on a checklist that routinely asks for privileged on-host runs. It skips instead of lying. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- docs/adr/0014-login-item-launch-marker.md | 9 ++- docs/contribute/testing.md | 5 ++ .../Sources/DezhbanMenu/AppDelegate.swift | 20 ++++++- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 17 +++++- .../Sources/DezhbanMenu/SettingsView.swift | 59 +++++++++---------- gui/macos/Sources/DezhbanMenu/main.swift | 14 ++++- .../HandoffRequestTests.swift | 6 ++ 8 files changed, 93 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c86cc7c..264b114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -496,7 +496,7 @@ current as you land changes. unwarranted repair; it is now treated as an unreadable check, the same discipline already applied to a fully failed query. - **A local, unprivileged process could block the Windows kill switch from - ever starting.** The single-session lock's mutex lived under a predictable + ever starting.** The single-instance lock's mutex lived under a predictable `Global\` name, so anyone could pre-create it first and either get treated as the legitimate "already running" holder or deny the daemon's own `CreateMutexW` with a hostile DACL. It now lives inside a boundary-restricted diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index de2a46c..ad80eea 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -190,8 +190,13 @@ with an argument, the pre-`SMAppService` pattern. app — once per debounce interval indefinitely, reopening a window the moment it was closed. Requiring a file outside the launch window costs nothing real (the file is written before the post, so only the microsecond gap between the two - needs the exemption) and removes the channel. The debounce is a rate limit, not a - gate. Both consumers do their claim off the main thread, since it is a stat and an + needs the exemption) and removes the channel — with one addition: a poster whose + file could not be written says so in the notification, because it is then the only + signal there will be and only the poster can know that. Requiring a file + unconditionally turned a full or read-only home into the silent no-op the + mechanism exists to prevent, and the exemption weakens nothing that was not + already reachable, since anything able to forge the notification could equally run + `open -a Dezhban`. The debounce is a rate limit, not a gate. Both consumers do their claim off the main thread, since it is a stat and an unlink and a network or relocated home would otherwise block the run loop on the one path meant to feel instant. diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index e42b424..301a9a2 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -809,6 +809,11 @@ task gui:build && open dist/Dezhban.app login" there is no window. This is `NSApp.disableRelaunchOnLogin()`; without it, LaunchServices relaunches the app with no arguments and races the agent for the lock. +- [ ] **The login toggle's result has its own line.** Start the service toggle + ("Start the guard at boot"), and while its privileged sequence is running flip + "Open this app at login". Both messages must be readable at once — the install's + progress on the pane's status line, the login result underneath the toggle — + and neither may erase the other. - [ ] **Two hand-offs in quick succession both open the window.** Only reachable while the incumbent is still starting — once it is up, LaunchServices reopens rather than launching a duplicate, so there is no hand-off to debounce. Log diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 6f3eb55..bfb0738 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -44,6 +44,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// `--background` login launch with no window to be handed over to. static let openWindowNotification = "com.behnam-rk.dezhban.app.openWindow" + /// Set by a poster whose hand-off file could not be written, so it is the only + /// signal there will be. Only the poster knows that, which is why it is carried + /// here rather than inferred by the receiver from a timer. + static let handoffFilelessKey = "dezhban.handoffFileless" + func applicationDidFinishLaunching(_: Notification) { // FIRST. A duplicate copy of the app posts this as it exits and then dies; // the notification is delivered immediately and never queued, so anything @@ -136,7 +141,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// A second copy of the app was started by the user and found this one /// already owning the session. Opening the window is the whole reason it /// bothered to tell us — it is standing in for the launch the user performed. - @objc private func openWindowRequested() { + @objc private func openWindowRequested(_ note: Notification) { // The claim goes off the main thread, like the backstop's: it is a stat and // an unlink, and on a network or relocated home — the case `uninstall.sh` // reads NFSHomeDirectory to accommodate — that blocks the run loop on the @@ -144,7 +149,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // // Whether the fileless fallback is allowed has to be read here though, // since it is main-thread state. - let backstopArmed = handoffTimer != nil + // Either the launch window, or the poster telling us its file never landed. + let fileless = (note.userInfo?[Self.handoffFilelessKey] as? String) == "1" + let acceptWithoutFile = handoffTimer != nil || fileless DispatchQueue.global(qos: .userInitiated).async { [weak self] in switch sessionHandoff?.claim() ?? .absent { case .fresh: @@ -167,7 +174,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // derivable. Unbounded, any process running as this user could call // `MainWindow.open()` — which activates the app — once per debounce // interval forever, reopening a window the moment it was closed. - guard backstopArmed else { return } + // + // The one exemption is a poster that says its file could not be + // written: it is then the only signal there will be, and only the + // poster can know that. It weakens nothing that was not already + // reachable — anything able to forge this could equally run + // `open -a Dezhban` — and without it a full or read-only home turned + // the hand-off into the silent no-op it exists to prevent. + guard acceptWithoutFile else { return } DispatchQueue.main.async { self?.openForHandoff(definite: false) } case .lost: // The backstop got there first and is opening the window. diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index e9600df..bf89cbc 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -273,7 +273,22 @@ enum LoginItem { // nothing the app can do will make enabling this safe. See // `Outcome.legacyStuck`. retractLegacy() - if registered(.mainApp) { return .blockedByLegacy } + if registered(.mainApp) { + // Which outcome depends on what actually survived, not on the direction + // clicked. A legacy item still *enabled* is starting the app at login, + // so `.legacyStuck` (isOn true) is the true report; only a dormant + // `.requiresApproval` leftover means nothing is starting it. + // + // `.blockedByLegacy` used to be returned for both, on the argument that + // `enable()` is only entered when the switch read OFF so `isEnabled` must + // have been false. That assumes a fresh switch — and the rest of this + // code deliberately treats it as stale, which is exactly why + // `set(enabled:)` takes the state the user asked for instead of + // re-reading. Re-approving the "Dezhban" row in System Settings arms the + // legacy registration behind a switch showing OFF, and clicking it then + // reported "left off" over a live login launch. + return legacyEnabled ? .legacyStuck : .blockedByLegacy + } UserDefaults.standard.set(false, forKey: userDisabledKey) // Flushed, like every other write to these three coupled flags. This pane // can have its process killed by launchd mid-operation, and clearing the diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 8125bdd..a61132a 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -37,16 +37,17 @@ struct SettingsView: View { /// while a mutation is outstanding, no read may write the switch, whichever /// order the two happen to complete in. @State private var loginPending = false - /// Until when `seed()` must leave the status line alone. + /// The login item's own result line. /// - /// `seed()` runs on every `didBecomeActiveNotification`, and macOS delivers one - /// *during* a login-item change because it surfaces System Settings or an - /// approval prompt. So the user came back from that prompt and `seed()` promptly - /// overwrote the line with "Loading…" and then "Seeded from …" — swallowing the - /// `awaitingApproval` and `legacyStuck` guidance, which is the entire reason - /// `Outcome` carries a message. `loginPending` already protects `loginEnabled` - /// from this same race; the status line needed its own. - @State private var loginStatusHoldUntil: Date? + /// Separate from the pane's shared `status` because the two are different facts + /// with different lifetimes: `seed()` rewrites `status` on every + /// `didBecomeActiveNotification` — which macOS delivers *during* a login-item + /// change, since it surfaces System Settings or an approval prompt — and the + /// service toggle owns it for the length of a privileged sequence. Sharing the + /// line meant the `awaitingApproval` and `legacyStuck` guidance, the entire + /// reason `Outcome` carries a message, could be wiped before it was read; and + /// guarding it in one direction only moved the loss to the other. + @State private var loginMessage: String? @State private var notifyPrefs = NotificationManager.prefs @State private var checkUpdatesEnabled = true @State private var launchVisibility: LaunchVisibility = .bootOnly @@ -197,6 +198,19 @@ struct SettingsView: View { Toggle("Open this app at login", isOn: loginBinding) .help("Registers the app as a login item (System Settings → General → Login Items). " + "This is only the status display — the guard itself is the system service above.") + // Its own line, not the pane's shared `status`. The login item is + // the one control here whose result can be a several-second + // round-trip AND can need the user to go and do something in + // System Settings, so it was competing with the service toggle's + // progress message for a single line: each clobbered the other, + // and a guard against one direction only moved the loss to the + // other. Two facts, two lines. + if let loginMessage { + Text(loginMessage) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } Picker("Open minimized", selection: launchVisibilityBinding) { ForEach(LaunchVisibility.allCases) { choice in Text(choice.label).tag(choice) @@ -799,22 +813,13 @@ struct SettingsView: View { AppActions.capturedSequence(wantInstalled ? AppActions.installCommands : AppActions.uninstallCommands) { result in bootBusy = false - // Not while a login-item message is owed. The login toggle is not - // disabled during this sequence, so a user can flip it mid-install, get - // `awaitingApproval` guidance — the one message they must act on for the - // switch not to be lying — and have this clear it a moment later. - if !holdingLoginStatus { status = "" } + status = "" if !result.ok { state.showInLogs(title: "\(title) — failed", text: result.output) } } } - /// Whether a login-item message is still owed the user's attention. - private var holdingLoginStatus: Bool { - loginPending || (loginStatusHoldUntil.map { $0 > Date() } ?? false) - } - private var loginBinding: Binding { Binding( get: { loginEnabled }, @@ -841,8 +846,7 @@ struct SettingsView: View { let revision = loginRevision loginPending = true loginEnabled = wanted - let inProgress = wanted ? "Registering the login item…" : "Removing the login item…" - status = inProgress + loginMessage = wanted ? "Registering the login item…" : "Removing the login item…" // The enqueueing form, so two quick clicks are applied in the order // they were made — dispatching each to a concurrent queue let them // race into LoginItem's serial queue and land out of order. @@ -855,14 +859,7 @@ struct SettingsView: View { // seconds later, so it is only written if nothing else has // claimed it since — otherwise a login result overwrites, say, // "Installing service…" while that install is still running. - // The hold exists to protect a message that is on screen, so - // it is only taken when one was actually written. Setting it - // unconditionally suppressed seed()'s status updates for ten - // seconds to defend a line that had been declined. - if status == inProgress { - status = outcome.message - loginStatusHoldUntil = Date().addingTimeInterval(10) - } + loginMessage = outcome.message } }) } @@ -986,7 +983,7 @@ struct SettingsView: View { // stale `true` is also what keeps the control enabled. tokenEnrolled = ControlToken.isStored refreshTokenCapability() - if !holdingLoginStatus { status = "Loading…" } + status = "Loading…" canApply = false // Off-main for the same reason `set(enabled:)` is: this is two blocking // SMAppService status reads over XPC (it was one before the agent), and @@ -1031,7 +1028,7 @@ struct SettingsView: View { // seeded snapshot are the same thing at this instant and the pane // starts out clean. seededValues = fields.currentValues - if !holdingLoginStatus { status = "Seeded from \(path)" } + status = "Seeded from \(path)" canApply = true } } diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index 438fdbb..0e24ffd 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -164,13 +164,25 @@ func acquireSessionOwnership() -> SessionLock? { // with no observer installed — the file is the one that waits, and the // incumbent's launch-time backstop finds it. Whichever of the two gets // there claims it, so the window opens once (see HandoffRequest). + var fileLanded = true if case .failure(let error) = sessionHandoff?.post() ?? .success(()) { NSLog("DezhbanMenu: could not record the hand-off request: \(error)") + fileLanded = false } + // Whether the file landed travels WITH the notification, because this + // process is the only one that knows. The incumbent requires a file + // outside its launch-time backstop window — that is what keeps an + // unauthenticated notification from being an activate-on-demand channel + // — so without this, a failed write (read-only or full home, wrong + // permissions on the support directory: the same conditions the session + // lock is written to tolerate) turned a launch the notification alone + // used to handle into a visible no-op, which is the one outcome this + // whole mechanism exists to prevent. DistributedNotificationCenter.default().postNotificationName( NSNotification.Name(AppDelegate.openWindowNotification), object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path, - userInfo: nil, deliverImmediately: true) + userInfo: fileLanded ? nil : [AppDelegate.handoffFilelessKey: "1"], + deliverImmediately: true) } NSLog("DezhbanMenu: another copy of this install owns the session; exiting") exit(0) diff --git a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift index 61ed166..8d7aeaf 100644 --- a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift @@ -65,6 +65,12 @@ struct HandoffRequestTests { /// Folded together, a permanent failure made every claimer stand down forever /// and killed the mechanism silently. @Test func anUnremovableRequestIsBlockedNotLost() throws { + // Root ignores directory permissions, so the mechanism this test uses to + // make a file unremovable does not work for it — the unlink succeeds, + // `claim()` returns `.fresh`, and the test would fail for a reason that has + // nothing to do with the code. testing.md routinely asks contributors to run + // privileged checks on this machine, so skip rather than lie. + try #require(getuid() != 0, "cannot make a file unremovable for root") let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } let request = HandoffRequest(url: dir.appendingPathComponent("h.handoff")) From 9aac8131c40c5f426283ee82d713b2a5f24ac6f2 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 05:02:39 +0330 Subject: [PATCH 21/36] fix(gui): the Settings switch must respect the install location too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twentieth review round, four findings. enable() had no install-location gate, only the migration did, and the waiver was "an explicit toggle from Settings is the user's own call". That misses who can undo it: register() records the *calling* bundle, and only that bundle can ever unregister(). So switching login-at-launch on from dist/Dezhban.app — the documented dev loop, which SessionLock is path-keyed specifically to permit — leaves a launchd registration pointing into a bundle the next build deletes. It fails to load at every login, orphans a row in Login Items, and nothing in the product can retract it, since uninstall.sh only searches the Applications directories. Same for a zip copy run once from ~/Downloads, which is the case the migration's gate already exists to avoid. The toggle is gated the same way now and says why. A copy that could not take the session lock and started anyway — the .unavailable path, which exists so a broken support directory cannot stop the app launching — still installed the hand-off observer and backstop. Both it and the real owner would answer one double-click, each with its own debounce, so neither could suppress the other: two windows and two activations. Only the owner answers now. loginMessage was never cleared, so guidance outlived the condition it described: "macOS is holding this for your approval" stayed on screen after the user went to System Settings, approved it, and came back — which is the very activation that re-seeds the switch to ON. A fresh status read clears it and lets the switch speak for itself. And a comment on the login completion still described a claim check against the pane's shared status line, which the split removed two commits ago — inviting a future reader to restore a guard that would undo the split. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +- docs/adr/0014-login-item-launch-marker.md | 16 +++++++ docs/contribute/testing.md | 7 +++ .../Sources/DezhbanMenu/AppDelegate.swift | 46 ++++++++++++------- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 25 +++++++++- .../Sources/DezhbanMenu/SettingsView.swift | 13 ++++-- gui/macos/Sources/DezhbanMenu/main.swift | 10 ++++ 7 files changed, 99 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 264b114..a21ca1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,9 @@ current as you land changes. next click turn login-at-launch *on*. If you had switched Dezhban off under System Settings → General → Login Items, upgrading leaves it off; and a copy of the app run from somewhere other than `/Applications` no longer claims the - login item for a location it is about to be moved out of. The login switch also + login item for a location it is about to be moved out of — and neither does the + Settings switch, which now says so rather than registering a login item that would + break the moment the copy moves. The login switch also no longer freezes the Settings window while macOS thinks about it, and reads OFF rather than ON when the item you see is one you had already switched off in System Settings. Uninstalling clears Dezhban's saved app preferences too — diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index ad80eea..3a5092e 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -126,6 +126,12 @@ with an argument, the pre-`SMAppService` pattern. purged lock file while the incumbent holds its descriptor means the next launch creates a fresh inode, locks *that*, and runs a second copy undetectably. + Only the session owner answers hand-offs. A copy that could not take the lock and + started anyway — the `.unavailable` path, which exists so a broken support + directory cannot stop the app launching — runs beside the real owner, and if both + answered, one double-click would open two windows, each with its own debounce, so + neither could suppress the other. + A launch the *user* performed must never become a silent no-op, so the copy that loses the lock focuses the winner and posts a distributed notification asking it to open its own, since the incumbent may be a `--background` login @@ -364,6 +370,16 @@ with an argument, the pre-`SMAppService` pattern. asking the presence question, because a `.requiresApproval` registration is still a registration to retract. + Enabling the login item from Settings is gated on the install location too, not + only the migration. The waiver it used to carry — "an explicit toggle is the user's + own call" — missed that the consequence is not the user's to undo: `register()` + records the calling bundle, and only that bundle can ever `unregister()`. So + toggling it on from `dist/Dezhban.app`, the dev loop `SessionLock` is path-keyed + specifically to allow, leaves a registration pointing into a bundle the next build + deletes — failing to load at every login, orphaning a row in Login Items, and + retractable by nothing, since the uninstaller only searches the Applications + directories. + And the migration runs only from `/Applications`, without marking the account migrated otherwise. `register()` records the plist of the *calling* bundle (`BundleProgram` is bundle-relative) while the flag is shared by every copy of diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 301a9a2..35270fb 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -841,6 +841,13 @@ task gui:build && open dist/Dezhban.app Applications directory counts as a place the app will stay; comparing only the immediate parent left that user's legacy item running with no marker permanently, reported nowhere. +- [ ] **A copy run from outside /Applications cannot claim the login item at all.** + Run `dist/Dezhban.app` and switch "Open this app at login" on: it must refuse, + with the line naming where it is running from, and + `launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` must still fail. + Only the registering bundle can ever retract a registration, so a dev build + that claimed it would leave an orphan nothing can remove once `dist` is + rebuilt. - [ ] **A copy run from outside /Applications does not migrate the login item.** Unzip `Dezhban-macos.app.zip` to `~/Downloads` on a Mac with a pre-agent install and login-at-launch on, run it once, quit. The legacy login item diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index bfb0738..a8e6449 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -50,22 +50,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { static let handoffFilelessKey = "dezhban.handoffFileless" func applicationDidFinishLaunching(_: Notification) { - // FIRST. A duplicate copy of the app posts this as it exits and then dies; - // the notification is delivered immediately and never queued, so anything - // ahead of this line is time in which a user-initiated launch is silently - // dropped — the one outcome `acquireSessionOwnership` exists to prevent. - // Scoped to the bundle path, which is what the poster sends: the name - // comes from the bundle id, and two installs of the app may legitimately - // run side by side (see SessionLock). - DistributedNotificationCenter.default().addObserver( - self, selector: #selector(openWindowRequested), - name: NSNotification.Name(Self.openWindowNotification), - object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path) - // And the file the notification cannot cover: a duplicate that posted - // while this process was still starting up found no observer, so its - // request is on disk. Checked now and for a few seconds more — see - // startHandoffBackstop for why it is bounded rather than on every tick. - startHandoffBackstop() + // FIRST, and only for the session owner. A duplicate posts its hand-off as + // it exits and then dies; the notification is delivered immediately and + // never queued, so anything ahead of this is time in which a user-initiated + // launch is silently dropped — the one outcome `acquireSessionOwnership` + // exists to prevent. + // + // A copy that could not take the lock and started anyway (the `.unavailable` + // path, for a broken support directory) is running beside the real owner. If + // both answered, one double-click would open two windows, each with its own + // debounce, so neither could suppress the other. + if sessionOwnsLock { + installHandoffHandling() + } // macOS has a second way to start this app at login, and it does not pass // the launch marker: "Reopen windows when logging back in" relaunches // whatever was running at logout, through LaunchServices, with no @@ -227,6 +224,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// (0.5s), short enough that two genuinely separate launches both get a window. private static let handoffDebounce: TimeInterval = 3 + /// Installs both hand-off consumers. Called only by the session owner. + /// + /// Scoped to the bundle path, which is what the poster sends: the name comes + /// from the bundle id, and two installs of the app may legitimately run side by + /// side (see `SessionLock`). + private func installHandoffHandling() { + DistributedNotificationCenter.default().addObserver( + self, selector: #selector(openWindowRequested), + name: NSNotification.Name(Self.openWindowNotification), + object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path) + // And the file the notification cannot cover: a duplicate that posted while + // this process was still starting up found no observer, so its request is on + // disk. Checked now and for a few seconds more — see startHandoffBackstop for + // why it is bounded rather than on every tick. + startHandoffBackstop() + } + /// The notification's backstop, for the gap before the observer above exists. /// /// Bounded on purpose. The window it covers is a launch-time one — the lock is diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index bf89cbc..ef1b798 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -96,6 +96,9 @@ enum LoginItem { /// switch OFF while the registration was live and the app kept starting /// at login. Exactly what `isEnabled`'s docstring says it exists to stop. case agentStuck + /// This copy of the app is not somewhere it will stay, so it must not claim + /// the login item. + case unstableLocation(String) /// Registration failed outright, and nothing is registered. case failed(String) @@ -104,7 +107,7 @@ enum LoginItem { var isOn: Bool { switch self { case .enabled, .awaitingApproval, .legacyStuck, .agentStuck: return true - case .disabled, .failed, .blockedByLegacy: return false + case .disabled, .failed, .blockedByLegacy, .unstableLocation: return false } } @@ -130,6 +133,10 @@ enum LoginItem { case .agentStuck: return "macOS would not remove the login item, so Dezhban will still open at " + "login. Remove \"Dezhban\" under System Settings → General → Login Items." + case .unstableLocation(let where_): + return "Dezhban has to live in Applications to open at login. This copy is " + + "running from \(where_), and a login item pointing there would break the " + + "moment it moves." case .failed(let why): return "Could not change the login item: \(why)" } @@ -249,6 +256,22 @@ enum LoginItem { } private static func enable() -> Outcome { + // Gated on the install location, exactly as the migration is. The waiver + // this used to carry — "an explicit toggle from Settings is the user's own + // call" — ignored that the consequence is not the user's to undo: + // `register()` records the *calling* bundle (`BundleProgram` is + // bundle-relative), and only the registering bundle can ever call + // `unregister()`. So toggling this on from `dist/Dezhban.app` — the + // documented dev loop, which `SessionLock` is path-keyed specifically to + // allow — leaves a launchd registration pointing into a bundle that the next + // build deletes: it fails to load at every login, leaves an orphan row in + // Login Items, and *nothing in the product can retract it*, since + // uninstall.sh only searches the Applications directories. Same for a zip + // copy run once from ~/Downloads, which is the case the migration's own gate + // exists to avoid. + guard isInStableInstallLocation else { + return .unstableLocation(Bundle.main.bundleURL.deletingLastPathComponent().path) + } // The agent must never be registered beside a live legacy item — that is // two launches at login, one with the marker and one without, and // whichever won the session lock would decide whether the window opened. diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index a61132a..9035f7a 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -855,10 +855,9 @@ struct SettingsView: View { guard revision == loginRevision else { return } loginPending = false loginEnabled = outcome.isOn - // `status` is the whole pane's line and this completion can land - // seconds later, so it is only written if nothing else has - // claimed it since — otherwise a login result overwrites, say, - // "Installing service…" while that install is still running. + // Written unconditionally: this is the login item's own line, + // so there is nothing else to collide with. That is the point of + // having split it from the pane's shared status. loginMessage = outcome.message } }) @@ -996,6 +995,12 @@ struct SettingsView: View { DispatchQueue.main.async { guard revision == loginRevision, !loginPending else { return } loginEnabled = enabled + // And clear the message, which described a moment that has passed. + // "macOS is holding this for your approval" outlived the approval: + // the user went to System Settings, granted it, came back — which is + // what fires this seed — and the pane still told them to go and do + // it. A fresh status read makes the switch speak for itself. + loginMessage = nil } } notifyPrefs = NotificationManager.prefs diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index 0e24ffd..ab767d4 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -60,6 +60,15 @@ func makeMainMenu() -> NSMenu { /// makes a request that arrived before the observer existed still get honoured. var sessionHandoff: HandoffRequest? +/// Whether this process actually holds the session lock. +/// +/// False on the `.unavailable` path, where the app deliberately starts anyway +/// rather than refuse to launch over a broken support directory. Such a copy is +/// running alongside the real owner, and must not also answer hand-offs: both would +/// open a window for one double-click, each with its own debounce, so the debounce +/// cannot suppress the second. +var sessionOwnsLock = false + /// Retracts every login registration and exits, without starting the app. /// /// The uninstaller needs this. A LaunchServices login item disappeared with its @@ -107,6 +116,7 @@ func acquireSessionOwnership() -> SessionLock? { sessionHandoff = HandoffRequest.beside(lock: lock.url) switch lock.acquire() { case .acquired: + sessionOwnsLock = true // Anything already on disk was meant for a predecessor, not for us. sessionHandoff?.discard() return lock From c0f34d348f4d232baa5f3ca655517e1253d0e1fe Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 05:29:20 +0330 Subject: [PATCH 22/36] fix(gui): a drift check that could not fail, and a flag written too late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-first review round, four findings, three of them mediums in machinery added over the last few rounds. The label-drift guard could not fail for LoginItem.swift. It grepped for the label as a bare substring, and LoginItem's own dispatch-queue label — "…app.loginitem" — contains it, so the check passed no matter what plistName said. Renaming plistName alone would have shipped an SMAppService call naming a plist that does not exist, reported only as the .notFound status nobody reads: exactly the drift ADR-0014 calls this assertion load-bearing for. Both consumers are now matched against their exact declaration, and the negative case is verified to fail the build. retractLegacy() wrote its flag after the unregister, which is the ordering disable() explicitly avoids two hundred lines away, for the same reason: SMAppService.mainApp is a launchd job, the migration's main case is a pre-agent install with login-at-launch on, so the running app IS that job's process and launchd may kill it as the job unloads. That kill left the legacy item retracted with nothing recorded, and the next launch — no legacy item, no flag — concluded there had never been one, marked the account migrated and returned. Nothing starting the app at login, permanently, which is the hole the flag was added to close. It now records the attempt, flushed first, so a retraction that succeeded unrecorded is not mistaken for one that never happened and a failed one is still re-attempted. disable() tested whether the legacy item was enabled while enable() tested whether one was registered, so the two directions disagreed about a .requiresApproval leftover that refuses to retract: disable reported a clean "App will not open at login", and every later click to turn it on was refused permanently, with nothing having warned them. Both derive from what actually survived now. And seed()'s loginMessage clear was not merely possible but guaranteed: its status read is a queue.sync behind the in-flight mutation, so it always completes after it, by which time loginPending is false — nilling the one line that tells the user to approve Dezhban in System Settings a moment after it appeared. The completion bumps the revision, so a read already in flight is discarded. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 11 +++++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 48 ++++++++++++++----- .../Sources/DezhbanMenu/SettingsView.swift | 8 ++++ gui/macos/build-app.sh | 26 ++++++---- 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 3a5092e..81f1988 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -350,6 +350,17 @@ with an argument, the pre-`SMAppService` pattern. switches login-at-launch off themselves: an explicit "off" outlives every retry, so a retry can only restore what was already on. + That third flag records the *attempt*, not the success, and is flushed before the + unregister rather than after it. `SMAppService.mainApp` is itself a launchd job and + the migration's main case is a pre-agent install with login-at-launch on, so the + running app is that job's process and launchd may kill it as the job unloads. + Written afterwards, that kill left the legacy item retracted with nothing recorded + — and the next launch, seeing no legacy item and no flag, concluded there had never + been one, marked the account migrated and returned. Nothing starting the app at + login, permanently, which is the hole the flag exists to close. Recording the + attempt also keeps a failed retraction re-attemptable, since the caller re-reads + the live status either way. + That retry needs a third flag to exist at all, which is not obvious and was got wrong first: by the time `register()` is reached the legacy item is already confirmed gone, so a retry launch that asks "is there a legacy item to diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index ef1b798..784c693 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -55,7 +55,7 @@ enum LoginItem { /// itself migrated and returns — never reaching `register()` again. The user /// is then left with nothing starting the app at login, permanently, which is /// the exact outcome the unmarked flag exists to prevent. - private static let legacyRetractedKey = "dezhban.loginItemLegacyRetracted" + private static let legacyRetractionAttemptedKey = "dezhban.loginItemLegacyRetractionAttempted" /// What a `set(enabled:)` actually achieved, so the UI can say something /// true. @@ -368,14 +368,20 @@ enum LoginItem { // marker, which is the state this function exists to clear. retractLegacy() if registered(service) { unregister(service, what: "login agent") } - if legacyEnabled { + if registered(.mainApp) { // The stuck path. Reported rather than worked around: registering the // agent alongside it would mean two launches at login, one with the // marker and one without, and whichever won the race would decide // whether the window appeared. `Outcome.legacyStuck` tells the user // the one thing that does clear it. NSLog("DezhbanMenu: the legacy login item could not be retracted") - return .legacyStuck + // Same derivation as `enable()`: an *enabled* survivor is still starting + // the app, a dormant one is not. Testing only `legacyEnabled` here while + // `enable()` tested presence made the two directions disagree about one + // state — a `.requiresApproval` leftover that would not retract reported + // a clean "App will not open at login", and then every future click to + // turn it on was refused, permanently, with nothing having warned them. + return legacyEnabled ? .legacyStuck : .blockedByLegacy } return registered(service) ? .agentStuck : .disabled } @@ -477,13 +483,16 @@ enum LoginItem { markMigrated() return } - } else if !UserDefaults.standard.bool(forKey: legacyRetractedKey) { - // Nothing was ever registered the old way on this account, so there is - // nothing to move onto the agent. Turning login-at-launch on is the - // user's call, via Settings. + } else if !UserDefaults.standard.bool(forKey: legacyRetractionAttemptedKey) { + // Nothing was ever registered the old way on this account, and no + // retraction was ever attempted, so there is nothing to move onto the + // agent. Turning login-at-launch on is the user's call, via Settings. markMigrated() return } + // Falling through means a retraction was attempted and the legacy item is + // gone — this launch, or an earlier one that was killed by the unload before + // it could finish. // Reached with the legacy item confirmed gone — now, or on an earlier // launch whose register() failed. @@ -507,7 +516,7 @@ enum LoginItem { /// Retracts the legacy item and records the fact if it worked. /// - /// The recording is the point. `legacyRetractedKey` is what tells "this + /// The recording is the point. `legacyRetractionAttemptedKey` is what tells "this /// account had a login item and the agent is not up yet" from "this account /// never had one", and while only the migration wrote it, retracting through /// the Settings switch destroyed the fact without recording it — reopening the @@ -517,19 +526,32 @@ enum LoginItem { /// and marks the account done with nothing starting the app at login. private static func retractLegacy() { guard registered(.mainApp) else { return } - unregister(.mainApp, what: "legacy login item") - guard !registered(.mainApp) else { return } - UserDefaults.standard.set(true, forKey: legacyRetractedKey) + // Recorded and flushed BEFORE the unregister, not after. `SMAppService.mainApp` + // is itself a launchd job, and the migration's main case is a pre-agent + // install with login-at-launch ON — so the running app *is* that job's + // process, and launchd may terminate it as the job is unloaded. Written + // afterwards, that kill left the legacy item retracted with nothing recorded: + // the next launch saw no legacy item and no flag, concluded there had never + // been one, marked the account migrated and returned. Nothing starting the + // app at login, permanently — the exact hole this flag was added to close. + // + // So it records the *attempt*, not the success. A retraction that fails is + // then re-attempted on the next launch (the caller re-reads `registered` + // either way), while one that succeeded without being recorded is no longer + // mistaken for "there was never anything here". `disable()` already flushes + // before this same call for the same reason. + UserDefaults.standard.set(true, forKey: legacyRetractionAttemptedKey) UserDefaults.standard.synchronize() + unregister(.mainApp, what: "legacy login item") } private static func markMigrated() { UserDefaults.standard.set(true, forKey: migratedKey) - // Flushed, because `legacyRetractedKey` is. Those two flags are read + // Flushed, because `legacyRetractionAttemptedKey` is. Those two flags are read // together and one outliving the other inverts the decision they encode: // this runs seconds into a login, and if the session ended before cfprefsd // wrote it, a legacy item retracted for a user who had login-at-launch OFF - // left `legacyRetractedKey` durable and `migratedKey` gone — so the next + // left `legacyRetractionAttemptedKey` durable and `migratedKey` gone — so the next // launch fell through to `register()` and turned it back on, which ADR-0014 // says must never happen. The mirror loss strands the account with nothing // starting the app at login. `disable()` already flushes before the call diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 9035f7a..13a6800 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -859,6 +859,14 @@ struct SettingsView: View { // so there is nothing else to collide with. That is the point of // having split it from the pane's shared status. loginMessage = outcome.message + // And bumped, so any status read that was already in flight + // cannot land afterwards and clear this. `loginPending` cannot + // cover it: seed()'s read is a queue.sync behind this very + // mutation, so it is *guaranteed* to complete after it, by which + // time pending is already false — and it then nils the one line + // that tells the user to go to System Settings, a moment after it + // appeared. + loginRevision += 1 } }) } diff --git a/gui/macos/build-app.sh b/gui/macos/build-app.sh index e94eb00..5ada820 100755 --- a/gui/macos/build-app.sh +++ b/gui/macos/build-app.sh @@ -116,16 +116,22 @@ fi # uninstall.sh (what retracts it). Renaming it consistently in the plist AND # here would otherwise satisfy check 1 while SMAppService named a file that # does not exist — reported only as the .notFound status nobody reads. -for consumer in "$HERE/Sources/DezhbanMenu/LoginItem.swift" "$REPO_ROOT/packaging/macos/uninstall.sh"; do - # -F: a fixed string, not a regex. Unanchored, `.` is a wildcard, so a label - # renamed to "…app-login" in one consumer still matched the pattern "…app.login" - # — the drift check passing over exactly the drift it exists to catch, leaving - # SMAppService naming a plist that does not exist. - if ! grep -qF "$AGENT_LABEL" "$consumer"; then - echo "build-app.sh: $consumer does not mention '$AGENT_LABEL' — the label, LoginItem.plistName and the uninstaller have drifted apart, and login-at-launch would fail silently" >&2 - exit 1 - fi -done +# +# Matched against the exact declaration in each consumer, not merely "the label +# appears somewhere in the file". Two reasons, both learned the hard way: without +# -F the pattern is a regex, so `.` is a wildcard and "…app-login" satisfied a +# check for "…app.login"; and a bare substring search for the label also matched +# LoginItem's dispatch-queue label, "…app.loginitem", which contains it — so the +# check could not fail for that file no matter what plistName said. +swift_decl="private static let plistName = \"$AGENT_LABEL.plist\"" +if ! grep -qF "$swift_decl" "$HERE/Sources/DezhbanMenu/LoginItem.swift"; then + echo "build-app.sh: LoginItem.swift does not declare plistName as '$AGENT_LABEL.plist' — SMAppService would name a plist that does not exist, reported only as the .notFound status nobody reads" >&2 + exit 1 +fi +if ! grep -qxF "LOGIN_AGENT=$AGENT_LABEL" "$REPO_ROOT/packaging/macos/uninstall.sh"; then + echo "build-app.sh: uninstall.sh does not set LOGIN_AGENT to '$AGENT_LABEL' — it would fail to retract the registration it is meant to remove" >&2 + exit 1 +fi # Documentation, rendered from the repo's own markdown into the bundle. Shipping # it means the help pane works with every byte of egress cut — which is exactly From cfe94b210d60eb1c448c76c647b1b42059cb1e4a Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 05:51:59 +0330 Subject: [PATCH 23/36] fix(gui): persist the user's "off" before retracting anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-second review round, two findings — the fewest yet, and the medium is the other half of last round's fix. Recording the retraction attempt durably before the unregister was right, but the fact that decides what happens *next* was still only a local. A pre-upgrade user who switched Dezhban off under System Settings leaves mainApp at .requiresApproval and never touched Dezhban's own switch, so nothing records their intent. If the process ended between the retraction and markMigrated() — a quit or logout during the status read that follows — the next launch saw no legacy item, saw the attempt flag set, fell through, and registered the agent: login-at-launch switched back on for someone who had deliberately turned it off, which ADR-0014 says must never happen, and userDisabledKey could not catch because it was never set. It is set now, flushed, before anything is retracted. The uninstaller's home-directory lookup was best-effort and every consumer degraded quietly. If it resolved to nothing while a console user existed — a directory record without NFSHomeDirectory, or any change in dscl/plutil output shape — the ~/Applications half of the bundle search was skipped, so an app installed there was declared "already gone" while it sat in place with its login agent intact, and the per-user cleanup was skipped under a closing message that said "files deleted". It falls back to the conventional path, and if even that is not there it warns instead of claiming a clean removal. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 9 +++++++++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 18 ++++++++++++++++++ packaging/macos/uninstall.sh | 17 +++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 81f1988..ad646ab 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -361,6 +361,15 @@ with an argument, the pre-`SMAppService` pattern. attempt also keeps a failed retraction re-attemptable, since the caller re-reads the live status either way. + The same discipline applies to the fact that *decides* what happens next. A + pre-upgrade user who switched Dezhban off under System Settings leaves `mainApp` at + `.requiresApproval` and never touched Dezhban's own switch, so nothing records + their intent — and while "was it enabled" lived only in a local, an interruption + between the retraction and `markMigrated()` left the next launch seeing no legacy + item, seeing the attempt flag, falling through, and registering the agent: + login-at-launch back on for someone who had deliberately turned it off. Their off + is now persisted before anything is retracted. + That retry needs a third flag to exist at all, which is not obvious and was got wrong first: by the time `register()` is reached the legacy item is already confirmed gone, so a retry launch that asks "is there a legacy item to diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 784c693..f12252c 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -466,6 +466,24 @@ enum LoginItem { // `disable()` the old comment pointed at. Retracting it honours the same // "off" while removing the way back to the defect. let wasEnabled = legacyEnabled + if !wasEnabled { + // Their "off" is recorded durably BEFORE anything is retracted, for + // the same reason `retractLegacy` flushes before its unregister — + // except that this is the fact which decides what happens next, and + // it lived only in the local above. + // + // A pre-upgrade user who switched Dezhban off under System Settings + // leaves `mainApp` at `.requiresApproval` and never touched Dezhban's + // own switch, so `userDisabledKey` is unset. If the process ended + // after the retraction was recorded but before `markMigrated()` — a + // quit or a logout during the status read that follows — the next + // launch saw no legacy item, saw the attempt flag set, fell through, + // and registered the agent: login-at-launch switched back ON for + // someone who had deliberately turned it off, which ADR-0014 says + // must never happen. + UserDefaults.standard.set(true, forKey: userDisabledKey) + UserDefaults.standard.synchronize() + } retractLegacy() if registered(.mainApp) { // Stuck. The agent is left unregistered rather than stacked on top diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index ca06402..213058f 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -80,6 +80,15 @@ if [ -n "$CONSOLE_USER" ] && [ "$CONSOLE_USER" != "root" ]; then # have. CONSOLE_HOME=$(dscl -plist /Search -read "/Users/$CONSOLE_USER" NFSHomeDirectory 2>/dev/null | plutil -extract 'dsAttrTypeStandard:NFSHomeDirectory.0' raw -o - -- - 2>/dev/null) + # The lookup is best-effort — a directory record without NFSHomeDirectory, or any + # change in dscl/plutil output shape, yields nothing — and every consumer below + # degrades quietly: the ~/Applications half of the bundle search is skipped, and + # so is the per-user cleanup, under a closing message that says "files deleted". + # The conventional path is a better guess than no guess, and if even that is not + # there the script says so rather than reporting a clean removal. + if [ -z "$CONSOLE_HOME" ] && [ -d "/Users/$CONSOLE_USER" ]; then + CONSOLE_HOME="/Users/$CONSOLE_USER" + fi fi # The bundle is looked for, not assumed. The app is allowed to register the login @@ -203,6 +212,8 @@ if [ -n "$CONSOLE_UID" ]; then # CONSOLE_HOME is resolved up top, where the bundle search needs it too. if [ -n "$CONSOLE_HOME" ] && [ -d "$CONSOLE_HOME" ]; then rm -rf "$CONSOLE_HOME/Library/Application Support/$APP_BUNDLE_ID" + elif [ "$LOGIN_ITEM_STUCK" = "none" ]; then + LOGIN_ITEM_STUCK=no-home fi # The app's preferences, for the same reason. These are not cosmetic: the # migration that moves an old LaunchServices login item onto the login agent @@ -266,6 +277,12 @@ none) ;; echo "warning: the app bundle was already gone, so its login item could not" echo " be retracted — only the app itself can do that." ;; + no-home) + echo "warning: $CONSOLE_USER's home directory could not be resolved, so" + echo " Dezhban's per-user leftovers were not removed — the session" + echo " lock, and the saved preferences that would make a later" + echo " reinstall skip the login-item migration." + ;; no-console-user) echo "warning: nobody is logged in, so Dezhban's per-user leftovers could not" echo " be removed — its login item, and the saved preferences that" From e4444ae8428b28ca26f6a82b98be27d3b36d1089 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 06:15:26 +0330 Subject: [PATCH 24/36] fix(gui): answer from live state, not from the branch you are in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-third review round, seven findings. Both "the legacy item survived" branches answered from their own branch — legacyEnabled ? .legacyStuck : .blockedByLegacy — which ignores the agent. With a dormant .requiresApproval legacy item that will not retract AND a live agent registration, that returned isOn false and a message asserting "it has been left off", so the switch snapped off, told the user nothing starts the app at login, and the next seed() flipped it back — while isEnabled said true. Both branches now derive from what is actually live, through one helper, so isOn agrees with isEnabled by construction and the two directions cannot drift apart again. This is the third time these two have disagreed about the same state; deriving rather than deciding per-branch is what finally makes that structural. The notification handler stood down on .blocked, which is right for the backstop (it would repeat every tick) and wrong here: this is a one-shot event tied to a real user launch, so standing down made that launch — and every later one, since an unremovable request stays — the silent no-op the mechanism exists to prevent. Notable that the mirror case, a file that could not be *written*, got a whole notification flag last round and this one was left. The debounce could not cover the ordering it was written for. When the backstop claims .fresh and the notification therefore sees .absent, both hop to main; if the .absent hop lands first it opens, and the .fresh hop then bypasses the debounce entirely. A definite open is now suppressed when the open it follows was indefinite — that pairing is two signals for one request — while two genuine double-clicks both claim .fresh and so never suppress each other. Four in the uninstaller: the no-home warning claimed the saved preferences survived when they are deleted unconditionally; a console user whose uid could not be looked up was told "nobody is logged in — re-run from a graphical session", which is false and useless since it fails the same way; the timeout's pkill was unscoped and reached every logged-in account's menubar app on a Mac using fast user switching; and a comment still asserted the Settings-toggle waiver that this same branch removed. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/DezhbanMenu/AppDelegate.swift | 28 +++++++++---- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 40 ++++++++++++++----- packaging/macos/uninstall.sh | 32 ++++++++++++--- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index a8e6449..bfb4051 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -25,6 +25,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// When a hand-off last opened the window, so two signals for one request /// cannot open it twice — see `openForHandoff`. private var lastHandoffOpenAt: Date? + /// Whether that open came from a definitive claim. See `openForHandoff`. + private var lastHandoffOpenWasDefinite = false private var snapshot: Snapshot? private var lastMtime: Date? private var lastIconKey: String? @@ -184,10 +186,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // The backstop got there first and is opening the window. break case .blocked(let why): - // Not a race — the request cannot be removed, so acting on it would - // repeat on every check. Logged because this is permanent: the - // hand-off is dead for every future launch until it is fixed. + // The request cannot be removed — a delete-denying ACL, `chflags + // uchg`. Permanent, so the *backstop* stands down (it would repeat + // every tick), but this handler is a one-shot event tied to a real + // user launch: standing down here would make that launch the silent + // no-op the mechanism exists to prevent, and every launch after it. + // Debounced as indefinite, since the backstop may have acted too. NSLog("DezhbanMenu: hand-off request could not be claimed: \(why)") + DispatchQueue.main.async { self?.openForHandoff(definite: false) } } } } @@ -211,12 +217,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// may be describing a request another caller already handled, are debounced. private func openForHandoff(definite: Bool) { let now = Date() - if !definite, - let last = lastHandoffOpenAt, - now.timeIntervalSince(last) < Self.handoffDebounce { - return + if let last = lastHandoffOpenAt, now.timeIntervalSince(last) < Self.handoffDebounce { + // An indefinite open never repeats inside the window. + if !definite { return } + // A definitive one is suppressed only when the open it would follow was + // *indefinite* — that pairing is the two signals for a single request + // (the backstop claimed `.fresh` while the notification saw `.absent`, + // and the `.absent` hop reached main first). Two genuine double-clicks + // both claim `.fresh`, so a definite open never suppresses another + // definite one, which is what keeps a real second request from being + // swallowed. + if !lastHandoffOpenWasDefinite { return } } lastHandoffOpenAt = now + lastHandoffOpenWasDefinite = definite MainWindow.shared.open() } diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index f12252c..89410fb 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -310,7 +310,7 @@ enum LoginItem { // re-reading. Re-approving the "Dezhban" row in System Settings arms the // legacy registration behind a switch showing OFF, and clicking it then // reported "left off" over a live login launch. - return legacyEnabled ? .legacyStuck : .blockedByLegacy + return liveOutcome(fallback: .blockedByLegacy) } UserDefaults.standard.set(false, forKey: userDisabledKey) // Flushed, like every other write to these three coupled flags. This pane @@ -375,13 +375,12 @@ enum LoginItem { // whether the window appeared. `Outcome.legacyStuck` tells the user // the one thing that does clear it. NSLog("DezhbanMenu: the legacy login item could not be retracted") - // Same derivation as `enable()`: an *enabled* survivor is still starting - // the app, a dormant one is not. Testing only `legacyEnabled` here while - // `enable()` tested presence made the two directions disagree about one - // state — a `.requiresApproval` leftover that would not retract reported - // a clean "App will not open at login", and then every future click to - // turn it on was refused, permanently, with nothing having warned them. - return legacyEnabled ? .legacyStuck : .blockedByLegacy + // Same derivation as `enable()`, and via the same helper so the two + // directions cannot drift apart again: they once disagreed about a + // `.requiresApproval` leftover that would not retract — disable reported + // a clean "App will not open at login" and every later click to turn it + // on was refused, permanently, with nothing having warned them. + return liveOutcome(fallback: .blockedByLegacy) } return registered(service) ? .agentStuck : .disabled } @@ -434,8 +433,9 @@ enum LoginItem { // the app zip before moving it, or a dev build — would point the login // agent at a bundle that is about to move or be deleted, and mark the // account done forever. The symptom is an SMAppService status nobody - // reads. This runs unattended, so it has to be the conservative one; an - // explicit toggle from Settings is the user's own call and is not gated. + // reads. `enable()` gates on the same thing, for the reason given there — + // the consequence is not the user's to undo, so an explicit toggle is no + // more entitled to claim the login item from a doomed bundle than this is. guard isInStableInstallLocation else { NSLog("DezhbanMenu: not migrating the login item from a non-standard location " + "(\(Bundle.main.bundleURL.path)); the copy in /Applications will do it") @@ -613,6 +613,26 @@ enum LoginItem { /// `CustomStringConvertible`, so interpolating it put /// `SMAppService.Status(rawValue: 3)` in front of the user — in the very type /// that exists so the UI can say something true. + /// The truthful outcome for whatever is live right now. + /// + /// Both "the legacy item survived" branches used to answer from their own + /// branch — `legacyEnabled ? .legacyStuck : .blockedByLegacy` — which ignored + /// the agent. With a dormant `.requiresApproval` legacy item that will not + /// retract AND a live agent registration, that returned `.blockedByLegacy`: + /// `isOn == false`, a message asserting "it has been left off", and a switch + /// snapping OFF while `isEnabled` said true and the next `seed()` flipped it + /// back. Deriving from the live state instead makes `isOn` agree with + /// `isEnabled` by construction, which is what `isEnabled`'s docstring demands. + /// + /// `fallback` is used only when nothing is live at all. + private static func liveOutcome(fallback: Outcome) -> Outcome { + if legacyEnabled { return .legacyStuck } + if agentEnabled { return .enabled } + if service.status == .requiresApproval { return .awaitingApproval } + if registered(service) { return .agentStuck } + return fallback + } + private static func describe(_ status: SMAppService.Status) -> String { switch status { case .notRegistered: return "macOS did not keep the registration." diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 213058f..00146b6 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -69,6 +69,13 @@ CONSOLE_UID="" CONSOLE_HOME="" if [ -n "$CONSOLE_USER" ] && [ "$CONSOLE_USER" != "root" ]; then CONSOLE_UID=$(id -u "$CONSOLE_USER" 2>/dev/null || echo "") + if [ -z "$CONSOLE_UID" ]; then + # Somebody IS at this Mac; their uid just could not be looked up (an + # unreachable network/AD directory, a damaged local record). Telling them + # "nobody is logged in — re-run from a graphical session" is both false and + # useless advice, since re-running fails the same way. + LOGIN_ITEM_STUCK=no-console-uid + fi # /Search, not the local node. `dscl .` reads only local records, so for a # network/LDAP/AD account it returns nothing — leaving the cleanup below to be # skipped for precisely the accounts this lookup exists to serve. @@ -190,7 +197,10 @@ if [ -n "$CONSOLE_UID" ]; then # bundle out from under it — the thing the `pkill` above exists to avoid, # reintroduced on the one path this timeout is here for. kill -9 "$errand" >/dev/null 2>&1 || true - pkill -x DezhbanMenu >/dev/null 2>&1 || true + # Scoped to the user the errand ran as. Unscoped, this reached every + # logged-in account's menubar app on a Mac using fast user switching — + # other people's sessions, over a timeout in this one. + pkill -x -U "$CONSOLE_UID" DezhbanMenu >/dev/null 2>&1 || true elif [ "$(cat "$errand_done" 2>/dev/null)" = "failed" ]; then # The status matters. The app only logs a refused unregister, and this # script discards its output — so without checking, a login item macOS @@ -232,7 +242,13 @@ else # entry that fails to load at every subsequent login, and a migration flag that # makes a LATER reinstall skip the migration. The same silent-clean-report the # other states were introduced to end. - LOGIN_ITEM_STUCK=no-console-user + # + # Only if nothing more specific was recorded: a console user whose uid could not + # be looked up also lands here, and "nobody is logged in" is the wrong thing to + # tell somebody sitting at the machine. + if [ "$LOGIN_ITEM_STUCK" = "none" ]; then + LOGIN_ITEM_STUCK=no-console-user + fi fi rm -rf "$APP" @@ -278,10 +294,14 @@ none) ;; echo " be retracted — only the app itself can do that." ;; no-home) - echo "warning: $CONSOLE_USER's home directory could not be resolved, so" - echo " Dezhban's per-user leftovers were not removed — the session" - echo " lock, and the saved preferences that would make a later" - echo " reinstall skip the login-item migration." + echo "warning: $CONSOLE_USER's home directory could not be resolved, so the" + echo " session lock under ~/Library/Application Support was left" + echo " behind. (The saved preferences were still removed.)" + ;; + no-console-uid) + echo "warning: could not look up $CONSOLE_USER's user id, so Dezhban's" + echo " per-user leftovers were not removed. This usually means the" + echo " directory service is unreachable; re-run once it is back." ;; no-console-user) echo "warning: nobody is logged in, so Dezhban's per-user leftovers could not" From 54bd686ede66d59354506fdd20b5c032b1f49d96 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 06:33:35 +0330 Subject: [PATCH 25/36] fix(gui): keep the explanation for a refusal the user has to act on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-fourth review round, four findings. seed() cleared loginMessage unconditionally, and it runs on every didBecomeActive — so clicking away from the app and back was enough to erase it. That was justified for "macOS is holding this for your approval", whose condition really does expire, but it also wiped the messages that explain a *refusal* and have no other channel: run dist/Dezhban.app, click the login toggle, watch it snap back with "Dezhban has to live in Applications to open at login", click another app and back, and the reason is gone. A switch that snaps back with no explanation is indistinguishable from a bug, which is the thing Outcome was introduced to prevent. Outcomes now say whether they are transient, and only those are cleared. .blockedByLegacy's message described a click the user may not have made — "switching this on could start Dezhban twice at login, so it has been left off" — and disable() reaches it too, when the agent goes away but a dormant legacy registration will not. Describing the wrong direction is the mistake splitting .legacyStuck out was supposed to end, so the wording is now neutral about which way was clicked. The uninstaller's leftover-home condition shared LOGIN_ITEM_STUCK, which holds one value — so whenever an earlier step had recorded timeout/refused/not-attempted, an unresolvable home was swallowed and the session lock survived under a report mentioning only the other problem. It has its own flag, and both warnings print. And ADR-0014 still carried the sentence "an explicit toggle from Settings is the user's own call and is not gated" fifteen lines after explaining why that waiver was wrong and removed. That is the sentence a future reader would act on to re-add it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 6 +++-- docs/contribute/testing.md | 6 +++++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 24 ++++++++++++++++-- .../Sources/DezhbanMenu/SettingsView.swift | 25 ++++++++++++++----- packaging/macos/uninstall.sh | 24 ++++++++++++------ 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index ad646ab..198c319 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -406,8 +406,10 @@ with an argument, the pre-`SMAppService` pattern. the app, so one launch from `~/Downloads` — an upgrader trying the app zip before moving it — or from `dist/` would point the login agent at a bundle about to move or be deleted and mark the account done forever. It runs - unattended, so it takes the conservative branch; an explicit toggle from - Settings is the user's own call and is not gated. + unattended, so it takes the conservative branch — and the Settings toggle takes + the same one, for the reason given above: the consequence is not the user's to + undo, so an explicit click is no more entitled to claim the login item from a + doomed bundle. Two smaller versions of the same "the switch must not lie" rule. `LoginItem.enable()` refuses to register the agent while a legacy item is live, diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 35270fb..fcac0af 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -809,6 +809,12 @@ task gui:build && open dist/Dezhban.app login" there is no window. This is `NSApp.disableRelaunchOnLogin()`; without it, LaunchServices relaunches the app with no arguments and races the agent for the lock. +- [ ] **A refusal's explanation survives switching away and back.** Run + `dist/Dezhban.app`, click "Open this app at login" — it refuses and says why. + Now click another app and click back to Dezhban: the explanation must still be + there. Only messages about a moment that has passed ("macOS is holding this + for your approval") may be cleared by that refresh; a switch that snapped + back with the reason erased is indistinguishable from a bug. - [ ] **The login toggle's result has its own line.** Start the service toggle ("Start the guard at boot"), and while its privileged sequence is running flip "Open this app at login". Both messages must be readable at once — the install's diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 89410fb..45b27e4 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -102,6 +102,21 @@ enum LoginItem { /// Registration failed outright, and nothing is registered. case failed(String) + /// Whether this message describes a moment that will pass on its own. + /// + /// `.awaitingApproval` expires the instant the user approves, and the two + /// plain states are legible from the switch itself — those may be cleared by + /// a refresh. The rest explain a *refusal*, and have no other channel: wiping + /// them leaves a switch that snapped back with no reason given, which is + /// indistinguishable from a bug and is the thing this type exists to prevent. + var isTransient: Bool { + switch self { + case .enabled, .disabled, .awaitingApproval: return true + case .legacyStuck, .agentStuck, .blockedByLegacy, .unstableLocation, .failed: + return false + } + } + /// Whether anything starts the app at login — what the Settings switch /// shows. var isOn: Bool { @@ -127,9 +142,14 @@ enum LoginItem { return "macOS would not remove the old login item, so Dezhban will still open " + "at login. Logging out and back in usually clears it." case .blockedByLegacy: + // Worded for neither direction, because both reach it: `enable()` + // refuses here, and `disable()` lands here too when the agent goes + // away but a dormant legacy registration will not. Describing a + // click the user may not have made is the mistake splitting + // `.legacyStuck` out was supposed to end. return "An old login-item registration is still on file and macOS will not " - + "remove it. Switching this on could start Dezhban twice at login, so it " - + "has been left off. Logging out and back in usually clears it." + + "remove it. Nothing starts Dezhban at login, and switching that on is " + + "blocked while it exists. Logging out and back in usually clears it." case .agentStuck: return "macOS would not remove the login item, so Dezhban will still open at " + "login. Remove \"Dezhban\" under System Settings → General → Login Items." diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 13a6800..636b9f5 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -48,6 +48,12 @@ struct SettingsView: View { /// reason `Outcome` carries a message, could be wiped before it was read; and /// guarding it in one direction only moved the loss to the other. @State private var loginMessage: String? + /// True while `loginMessage` explains a refusal the user has to act on. + /// + /// A refresh may clear a message about a moment that has passed; it must not + /// clear one that is the only account of why a click did not take. See + /// `LoginItem.Outcome.isTransient`. + @State private var loginMessageIsTransient = true @State private var notifyPrefs = NotificationManager.prefs @State private var checkUpdatesEnabled = true @State private var launchVisibility: LaunchVisibility = .bootOnly @@ -859,6 +865,7 @@ struct SettingsView: View { // so there is nothing else to collide with. That is the point of // having split it from the pane's shared status. loginMessage = outcome.message + loginMessageIsTransient = outcome.isTransient // And bumped, so any status read that was already in flight // cannot land afterwards and clear this. `loginPending` cannot // cover it: seed()'s read is a queue.sync behind this very @@ -1003,12 +1010,18 @@ struct SettingsView: View { DispatchQueue.main.async { guard revision == loginRevision, !loginPending else { return } loginEnabled = enabled - // And clear the message, which described a moment that has passed. - // "macOS is holding this for your approval" outlived the approval: - // the user went to System Settings, granted it, came back — which is - // what fires this seed — and the pane still told them to go and do - // it. A fresh status read makes the switch speak for itself. - loginMessage = nil + // Clear only a message about a moment that has passed. "macOS is + // holding this for your approval" outlived the approval — the user + // went to System Settings, granted it, came back, which is what + // fires this seed — and a fresh status read makes the switch speak + // for itself there. + // + // But not the ones explaining a refusal. Clicking away from the app + // and back is enough to fire this, so clearing unconditionally erased + // "Dezhban has to live in Applications to open at login" a moment + // after the switch snapped back, leaving exactly the unexplained + // snap-back this message exists to prevent. + if loginMessageIsTransient { loginMessage = nil } } } notifyPrefs = NotificationManager.prefs diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 00146b6..f938491 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -23,6 +23,9 @@ SHARE_DIR=/usr/local/share/dezhban LOGIN_AGENT=com.behnam-rk.dezhban.app.login APP_BUNDLE_ID=com.behnam-rk.dezhban.app LOGIN_ITEM_STUCK=none +# Separate from LOGIN_ITEM_STUCK, which holds one value: the two conditions are +# independent and both have to be reportable at once. +SUPPORT_DIR_KEPT=0 if [ "$(id -u)" -ne 0 ]; then echo "error: run as root — sudo sh $0" >&2 @@ -222,8 +225,12 @@ if [ -n "$CONSOLE_UID" ]; then # CONSOLE_HOME is resolved up top, where the bundle search needs it too. if [ -n "$CONSOLE_HOME" ] && [ -d "$CONSOLE_HOME" ]; then rm -rf "$CONSOLE_HOME/Library/Application Support/$APP_BUNDLE_ID" - elif [ "$LOGIN_ITEM_STUCK" = "none" ]; then - LOGIN_ITEM_STUCK=no-home + else + # Its own flag. Sharing LOGIN_ITEM_STUCK meant that whenever an earlier step + # had already recorded timeout/refused/not-attempted, an unresolvable home + # was swallowed — the session lock and any hand-off file surviving under a + # closing report that mentioned only the other problem. + SUPPORT_DIR_KEPT=1 fi # The app's preferences, for the same reason. These are not cosmetic: the # migration that moves an old LaunchServices login item onto the login agent @@ -293,11 +300,6 @@ none) ;; echo "warning: the app bundle was already gone, so its login item could not" echo " be retracted — only the app itself can do that." ;; - no-home) - echo "warning: $CONSOLE_USER's home directory could not be resolved, so the" - echo " session lock under ~/Library/Application Support was left" - echo " behind. (The saved preferences were still removed.)" - ;; no-console-uid) echo "warning: could not look up $CONSOLE_USER's user id, so Dezhban's" echo " per-user leftovers were not removed. This usually means the" @@ -315,6 +317,14 @@ none) ;; echo " remove it there." ;; esac + +if [ "$SUPPORT_DIR_KEPT" = "1" ]; then + echo + echo "warning: $CONSOLE_USER's home directory could not be resolved, so" + echo " ~/Library/Application Support/$APP_BUNDLE_ID was left behind." + echo " It holds only Dezhban's session lock. (The saved preferences" + echo " were removed.)" +fi echo echo "If any OTHER account on this Mac ran the app, its login agent is still" echo "registered there — root cannot reach another user's launchd session. Nothing" From 0dd71af3edf15308344246eab97727b0aba98ed1 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 06:52:12 +0330 Subject: [PATCH 26/36] fix(gui): the live state does not say which way the switch was moving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-fifth review round, three findings, and the medium is the cost of round twenty-three's fix. Deriving the outcome from live state stopped enable() and disable() disagreeing, but it made the answer direction-blind — and a registered agent means opposite things to the two callers. To someone disabling, it means the unregister failed; liveOutcome reported .enabled, "App will open at login.", to a user who had just clicked off. That made .agentStuck unreachable from disable() whenever a legacy registration also survived, which matters because .agentStuck is the only outcome carrying the line telling them to clear it in System Settings and the only non-transient one — so the message was wiped by the next refresh and the switch sat back ON explaining nothing. The same blindness had .awaitingApproval telling someone who clicked off to go and enable Dezhban. liveOutcome takes the direction now. The debounce left lastHandoffOpenWasDefinite false after suppressing a definite open, so the rest of the 3s window kept swallowing definite opens and a genuine second double-click a second later was dropped — the no-op the mechanism exists to prevent, reached through the machinery that prevents it. Suppressing closes the pair. And the transient flag was never reset when a click began, so a progress line inherited the previous refusal's "keep me" and became un-clearable if its completion was superseded. A refusal also outstayed its truth: the user cleared the condition in System Settings, came back, the switch moved, and the old refusal sat there contradicting it. Messages now remember what the switch read when they were written, and go when it no longer matches. Co-Authored-By: Claude Opus 5 (1M context) --- docs/contribute/testing.md | 14 ++++---- .../Sources/DezhbanMenu/AppDelegate.swift | 9 ++++- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 33 +++++++++++++++---- .../Sources/DezhbanMenu/SettingsView.swift | 18 +++++++++- 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index fcac0af..d03f20c 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -809,12 +809,14 @@ task gui:build && open dist/Dezhban.app login" there is no window. This is `NSApp.disableRelaunchOnLogin()`; without it, LaunchServices relaunches the app with no arguments and races the agent for the lock. -- [ ] **A refusal's explanation survives switching away and back.** Run - `dist/Dezhban.app`, click "Open this app at login" — it refuses and says why. - Now click another app and click back to Dezhban: the explanation must still be - there. Only messages about a moment that has passed ("macOS is holding this - for your approval") may be cleared by that refresh; a switch that snapped - back with the reason erased is indistinguishable from a bug. +- [ ] **A refusal's explanation survives switching away and back — and expires + when it stops being true.** Run `dist/Dezhban.app`, click "Open this app at + login" — it refuses and says why. Click another app and click back: the + explanation must still be there. Only messages about a moment that has passed + ("macOS is holding this for your approval") may be cleared by that refresh; a + switch that snapped back with the reason erased is indistinguishable from a + bug. Then clear the condition and return — once the switch moves, the stale + refusal must go with it rather than sit there contradicting it. - [ ] **The login toggle's result has its own line.** Start the service toggle ("Start the guard at boot"), and while its privileged sequence is running flip "Open this app at login". Both messages must be readable at once — the install's diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index bfb4051..3db1ca4 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -227,7 +227,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // both claim `.fresh`, so a definite open never suppresses another // definite one, which is what keeps a real second request from being // swallowed. - if !lastHandoffOpenWasDefinite { return } + if !lastHandoffOpenWasDefinite { + // And that pair is now closed. Leaving the flag false meant the rest + // of the 3s window kept swallowing definite opens, so a genuine + // second double-click a second later was dropped — the no-op this + // exists to prevent, reached through the machinery preventing it. + lastHandoffOpenWasDefinite = true + return + } } lastHandoffOpenAt = now lastHandoffOpenWasDefinite = definite diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 45b27e4..57b61d7 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -330,7 +330,7 @@ enum LoginItem { // re-reading. Re-approving the "Dezhban" row in System Settings arms the // legacy registration behind a switch showing OFF, and clicking it then // reported "left off" over a live login launch. - return liveOutcome(fallback: .blockedByLegacy) + return liveOutcome(.enabling, fallback: .blockedByLegacy) } UserDefaults.standard.set(false, forKey: userDisabledKey) // Flushed, like every other write to these three coupled flags. This pane @@ -400,7 +400,7 @@ enum LoginItem { // `.requiresApproval` leftover that would not retract — disable reported // a clean "App will not open at login" and every later click to turn it // on was refused, permanently, with nothing having warned them. - return liveOutcome(fallback: .blockedByLegacy) + return liveOutcome(.disabling, fallback: .blockedByLegacy) } return registered(service) ? .agentStuck : .disabled } @@ -644,12 +644,33 @@ enum LoginItem { /// back. Deriving from the live state instead makes `isOn` agree with /// `isEnabled` by construction, which is what `isEnabled`'s docstring demands. /// + /// Which way the user was moving the switch. + /// + /// The live state alone is not enough: a registered agent means "on, as asked" + /// to somebody enabling and "the unregister failed" to somebody disabling, and + /// those need different outcomes. Deriving without it reported `.enabled` — + /// "App will open at login." — to a user who had just clicked *off*, which made + /// `.agentStuck` unreachable from `disable()` whenever a legacy registration + /// also survived. `.agentStuck` is the only outcome carrying the line telling + /// them to clear it in System Settings, and the only non-transient one, so the + /// message was then wiped by the next refresh and the switch sat back ON with no + /// explanation. + enum Direction { case enabling, disabling } + /// `fallback` is used only when nothing is live at all. - private static func liveOutcome(fallback: Outcome) -> Outcome { + private static func liveOutcome(_ direction: Direction, fallback: Outcome) -> Outcome { if legacyEnabled { return .legacyStuck } - if agentEnabled { return .enabled } - if service.status == .requiresApproval { return .awaitingApproval } - if registered(service) { return .agentStuck } + switch direction { + case .enabling: + if agentEnabled { return .enabled } + if service.status == .requiresApproval { return .awaitingApproval } + if registered(service) { return .agentStuck } + case .disabling: + // Anything still registered after a disable is a retraction that failed, + // whatever status it wears. `.awaitingApproval` in particular would tell + // someone who clicked *off* to go and enable Dezhban. + if registered(service) { return .agentStuck } + } return fallback } diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 636b9f5..cf2c02f 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -54,6 +54,13 @@ struct SettingsView: View { /// clear one that is the only account of why a click did not take. See /// `LoginItem.Outcome.isTransient`. @State private var loginMessageIsTransient = true + /// What the switch read when `loginMessage` was written. + /// + /// A message that explains a refusal has to survive a refresh, but not forever: + /// once the user clears the condition in System Settings and comes back, the + /// switch moves and the refusal is false. Without this, that text stayed on + /// screen contradicting the switch, clearable only by another click. + @State private var loginMessageForEnabled: Bool? @State private var notifyPrefs = NotificationManager.prefs @State private var checkUpdatesEnabled = true @State private var launchVisibility: LaunchVisibility = .bootOnly @@ -853,6 +860,11 @@ struct SettingsView: View { loginPending = true loginEnabled = wanted loginMessage = wanted ? "Registering the login item…" : "Removing the login item…" + // A progress line is transient by nature. Left inheriting the + // previous outcome's value, a refusal's `false` made it un-clearable + // if this click's completion was then superseded. + loginMessageIsTransient = true + loginMessageForEnabled = wanted // The enqueueing form, so two quick clicks are applied in the order // they were made — dispatching each to a concurrent queue let them // race into LoginItem's serial queue and land out of order. @@ -866,6 +878,7 @@ struct SettingsView: View { // having split it from the pane's shared status. loginMessage = outcome.message loginMessageIsTransient = outcome.isTransient + loginMessageForEnabled = outcome.isOn // And bumped, so any status read that was already in flight // cannot land afterwards and clear this. `loginPending` cannot // cover it: seed()'s read is a queue.sync behind this very @@ -1021,7 +1034,10 @@ struct SettingsView: View { // "Dezhban has to live in Applications to open at login" a moment // after the switch snapped back, leaving exactly the unexplained // snap-back this message exists to prevent. - if loginMessageIsTransient { loginMessage = nil } + if loginMessageIsTransient || loginMessageForEnabled != enabled { + loginMessage = nil + loginMessageForEnabled = nil + } } } notifyPrefs = NotificationManager.prefs From 848d437d0639359bbc85d9734161b25850ab2a53 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 07:11:09 +0330 Subject: [PATCH 27/36] fix(gui): the switch cannot show "waiting for approval", so the line must stay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-sixth review round, three findings. .awaitingApproval was classed transient, on the reasoning that a fresh status read lets the switch speak for itself. It cannot: an awaiting-approval registration counts as registered, so the switch reads ON while nothing actually starts the app at login. Clearing the message on the next activation therefore left exactly the switch-versus-reality lie this type exists to prevent — and the activation in question is the one the approval prompt itself causes, so it happened every time. The message now expires on the status changing rather than on a refresh, which needed the pane to read both facts in one call so they describe the same instant. registered() maps .notFound to "not registered", which is the honest answer about this bundle's plist and the wrong one for "is there anything to retract". retractAll() returned true on it, so the uninstall errand exited 0 and the script printed a clean removal over a registration an earlier valid copy of the bundle may still hold — the orphan the errand exists to remove. disable() had the same shape, reporting "App will not open at login" without having attempted anything. The .blocked hand-off opened as indefinite, so the debounce dropped the second of two launches inside three seconds — and since a request that cannot be removed stays that way, every later pair too. There is no other claimant to pair with there (the backstop stands down on .blocked), so it is definite. Also removed isEnabled, which had no callers left once the pane needed the approval fact beside it. Its reasoning is cited by several comments and moved onto the accessor that replaced it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/contribute/testing.md | 6 ++ .../Sources/DezhbanMenu/AppDelegate.swift | 8 +- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 74 ++++++++++++++++--- .../Sources/DezhbanMenu/SettingsView.swift | 21 +++++- 4 files changed, 92 insertions(+), 17 deletions(-) diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index d03f20c..15a3903 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -889,6 +889,12 @@ task gui:build && open dist/Dezhban.app confirm the log says it will retry. Then fix the bundle and launch again — the agent must register. `defaults read com.behnam-rk.dezhban.app dezhban.loginItemMigratedToAgent` must be absent or 0 between the two. +- [ ] **The approval prompt's own guidance survives it.** Turn the login item off + *in System Settings*, then switch Dezhban's "Open this app at login" on: the + line says macOS is holding it for your approval. Click away and back **without + approving** — the line must still be there, because the switch reads ON either + way and cannot show the difference. Then approve it and return: the line must + go. - [ ] **An awaiting-approval registration can still be switched off.** Turn the login item off *in System Settings* (not in Dezhban), then switch Dezhban's "Open this app at login" on: the status line must say macOS is holding it diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 3db1ca4..fe95f1a 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -191,9 +191,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // every tick), but this handler is a one-shot event tied to a real // user launch: standing down here would make that launch the silent // no-op the mechanism exists to prevent, and every launch after it. - // Debounced as indefinite, since the backstop may have acted too. + // Definite, not indefinite. There is no other claimant to pair + // with — the backstop stands down on `.blocked` — so treating it as + // ambiguous only meant the debounce dropped the *second* of two + // launches inside three seconds, and, since a request that cannot be + // removed stays that way, every later pair too. NSLog("DezhbanMenu: hand-off request could not be claimed: \(why)") - DispatchQueue.main.async { self?.openForHandoff(definite: false) } + DispatchQueue.main.async { self?.openForHandoff(definite: true) } } } } diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 57b61d7..de6139e 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -111,12 +111,26 @@ enum LoginItem { /// indistinguishable from a bug and is the thing this type exists to prevent. var isTransient: Bool { switch self { - case .enabled, .disabled, .awaitingApproval: return true - case .legacyStuck, .agentStuck, .blockedByLegacy, .unstableLocation, .failed: + case .enabled, .disabled: return true + case .awaitingApproval, .legacyStuck, .agentStuck, .blockedByLegacy, + .unstableLocation, .failed: return false } } + /// Whether this outcome is waiting on the user approving it in System + /// Settings, so its message stops being true the moment they do. + /// + /// `.awaitingApproval` was classed transient on the grounds that a fresh + /// status read lets the switch speak for itself — but the switch cannot + /// express this state: `isEnabled` counts an awaiting-approval registration, + /// so it reads ON while nothing actually starts the app at login. Clearing + /// the message on the next activation therefore left exactly the + /// switch-versus-reality lie this type exists to prevent, and it is the + /// activation the *approval prompt itself* causes. It expires on the status + /// changing, not on a refresh. + var awaitsApproval: Bool { self == .awaitingApproval } + /// Whether anything starts the app at login — what the Settings switch /// shows. var isOn: Bool { @@ -192,6 +206,16 @@ enum LoginItem { /// because a pre-upgrade user never set it. private static var legacyEnabled: Bool { SMAppService.mainApp.status == .enabled } + /// The agent's plist cannot be resolved, so nothing can be asserted about a + /// registration an earlier, valid copy of the bundle may have left in launchd. + /// + /// `registered()` reads `.notFound` as "not registered", which is the honest + /// answer about *this* bundle's plist and the wrong one for "is there anything + /// to retract" — `retractAll()` returned true on it, so the uninstall errand + /// exited 0 and the script printed a clean removal over precisely the orphan it + /// exists to remove. + private static var agentUnresolvable: Bool { service.status == .notFound } + /// Whether a registration exists at all, as opposed to one that will start /// the app *right now*. /// @@ -233,15 +257,28 @@ enum LoginItem { /// launches nothing, and reporting it as ON contradicted the migration's own /// reading of that exact state ("their 'off' is the answer") and showed a /// switch that was on while nothing started the app. - static var isEnabled: Bool { - // On the mutation queue, so a read can never observe a change half-applied. - // `disable()` retracts the legacy item and then the agent; a read landing - // between those two saw the agent still registered and reported ON, and if - // its main-queue hop was enqueued after the mutation's own completion it - // overwrote the correct answer with that one — the switch reading ON with - // nothing starting the app at login, which is the failure the revision - // stamp in SettingsView was added for and could not close on its own. - queue.sync { registered(service) || legacyEnabled } + /// + /// Read together with `awaitingApproval`, in one call, because they must describe + /// the same instant. The pane keeps an `.awaitingApproval` message on screen + /// until this says the wait is over: `enabled` cannot express that difference, + /// since an awaiting-approval registration counts as registered. + /// + /// On the mutation queue, so a read can never observe a change half-applied. + /// `disable()` retracts the legacy item and then the agent; a read landing + /// between those two saw the agent still registered and reported ON, and if its + /// main-queue hop was enqueued after the mutation's own completion it overwrote + /// the correct answer with that one — the switch reading ON with nothing starting + /// the app at login, which is the failure the revision stamp in `SettingsView` + /// was added for and could not close on its own. + /// + /// This replaced a lone `isEnabled`, which had no callers left once the pane + /// needed the approval fact beside it — and a spare `queue.sync` accessor on this + /// type is an invitation to reintroduce the main-thread beachball it was moved + /// off for. + static var state: (enabled: Bool, awaitingApproval: Bool) { + queue.sync { + (registered(service) || legacyEnabled, service.status == .requiresApproval) + } } /// Sets login-at-launch to `enabled` and reports what actually happened. @@ -402,7 +439,14 @@ enum LoginItem { // on was refused, permanently, with nothing having warned them. return liveOutcome(.disabling, fallback: .blockedByLegacy) } - return registered(service) ? .agentStuck : .disabled + if registered(service) || agentUnresolvable { + // `agentUnresolvable` too: reporting "App will not open at login" when + // the plist could not even be resolved claims a retraction that was + // never attempted, over a registration an earlier copy of the bundle may + // still hold. + return .agentStuck + } + return .disabled } /// Retracts everything that could start this app at login, best effort. @@ -427,6 +471,12 @@ enum LoginItem { // unreachable afterwards, while the script printed "service // unregistered, files deleted". The orphan the errand exists to remove, // now silent. The exit status is what makes it visible. + // + // An unresolvable plist counts as a failure, not a success: it means + // nothing could be asserted either way, and claiming a clean retraction + // over a registration that may well survive is the one direction this + // must not err in. + if agentUnresolvable { return false } return !registered(service) && !registered(.mainApp) } } diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index cf2c02f..cce530f 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -61,6 +61,10 @@ struct SettingsView: View { /// switch moves and the refusal is false. Without this, that text stayed on /// screen contradicting the switch, clearable only by another click. @State private var loginMessageForEnabled: Bool? + /// True while `loginMessage` is waiting on the user approving Dezhban in System + /// Settings — a condition the switch cannot show, so only the status changing + /// may clear it. See `LoginItem.Outcome.awaitsApproval`. + @State private var loginMessageAwaitsApproval = false @State private var notifyPrefs = NotificationManager.prefs @State private var checkUpdatesEnabled = true @State private var launchVisibility: LaunchVisibility = .bootOnly @@ -865,6 +869,7 @@ struct SettingsView: View { // if this click's completion was then superseded. loginMessageIsTransient = true loginMessageForEnabled = wanted + loginMessageAwaitsApproval = false // The enqueueing form, so two quick clicks are applied in the order // they were made — dispatching each to a concurrent queue let them // race into LoginItem's serial queue and land out of order. @@ -879,6 +884,7 @@ struct SettingsView: View { loginMessage = outcome.message loginMessageIsTransient = outcome.isTransient loginMessageForEnabled = outcome.isOn + loginMessageAwaitsApproval = outcome.awaitsApproval // And bumped, so any status read that was already in flight // cannot land afterwards and clear this. `loginPending` cannot // cover it: seed()'s read is a queue.sync behind this very @@ -1019,10 +1025,10 @@ struct SettingsView: View { // see `loginRevision`. let revision = loginRevision DispatchQueue.global(qos: .userInitiated).async { - let enabled = LoginItem.isEnabled + let live = LoginItem.state DispatchQueue.main.async { guard revision == loginRevision, !loginPending else { return } - loginEnabled = enabled + loginEnabled = live.enabled // Clear only a message about a moment that has passed. "macOS is // holding this for your approval" outlived the approval — the user // went to System Settings, granted it, came back, which is what @@ -1034,9 +1040,18 @@ struct SettingsView: View { // "Dezhban has to live in Applications to open at login" a moment // after the switch snapped back, leaving exactly the unexplained // snap-back this message exists to prevent. - if loginMessageIsTransient || loginMessageForEnabled != enabled { + // Three ways a message stops being worth showing: it described a + // moment that has passed; it was waiting on an approval that has now + // been granted (or the registration is gone) — which the switch + // cannot show, so only this can clear it; or the switch has since + // moved away from the state it was written about. + let approvalSettled = loginMessageAwaitsApproval && !live.awaitingApproval + if loginMessageIsTransient + || approvalSettled + || (!loginMessageAwaitsApproval && loginMessageForEnabled != live.enabled) { loginMessage = nil loginMessageForEnabled = nil + loginMessageAwaitsApproval = false } } } From 4088ce4e64a4064b5f63d13c675bf03d4f33b4af Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 07:30:01 +0330 Subject: [PATCH 28/36] fix(gui): .notFound means "not registered", so stop reading it as "cannot tell" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-seventh review round, two highs — both of them last round's fix, which rested on an assumption about SMAppService that is false on this OS. .notFound is what `SMAppService.agent(plistName:).status` returns for an agent that was never registered, not only for a plist it cannot resolve. Verified here: with no Dezhban login item and no agent job present, the shipped ad-hoc bundle's `--unregister-login-item` errand exited 1. So treating that status as "nothing can be asserted" fired on the ordinary path, twice over. disable() returned .agentStuck on it, which is isOn true and non-transient — so the common case, a pre-agent install whose switch reads ON via the legacy item and whose agent was never registered, had the user click off, watch the switch snap back ON, and be told "macOS would not remove the login item, remove it in System Settings" over a retraction that had just succeeded. That is the switch-versus-reality lie the Outcome type exists to prevent, reached from the other side. retractAll() returned false on it, so the uninstaller recorded "refused" and printed the System Settings warning on every uninstall of an install where login-at-launch was never switched on — crying wolf on the ordinary path and eroding the signal on the path the warning was written for. The errand now exits 0 there, checked against the rebuilt bundle. The distinction those changes wanted is simply not available from a status read, so nothing infers it any more and a comment says why, so it is not attempted a third time. What stays truthful is an unregister that actually fails. Third finding: seed() read `awaitingApproval` only to decide whether to clear a message a click had left behind, so opening Settings with no preceding click — on an install whose agent the user had switched off under Login Items — painted the switch ON with nothing said while nothing started the app at login. The switch counts an awaiting-approval registration, so it cannot express that; the pane now states it unprompted. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 9 +++++ docs/contribute/testing.md | 12 ++++++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 37 ++++++++----------- .../Sources/DezhbanMenu/SettingsView.swift | 13 +++++++ 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 198c319..88b454a 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -247,6 +247,15 @@ with an argument, the pre-`SMAppService` pattern. otherwise be told its bundle was already gone — while it went on launching at every login and the script reported everything removed. + One thing the app cannot tell the script: whether a registration exists that this + bundle's plist can no longer describe. `SMAppService`'s `.notFound` looks like it + carries that, and on this OS it is simply what an agent that was never registered + reports — verified against the shipped ad-hoc bundle. Treating it as "cannot tell" + made every uninstall of an install where login-at-launch had never been switched on + warn that macOS refused to retract it, and had `disable()` snap the switch back on + over a retraction that had just succeeded. So nothing infers it, and the honest + signal is narrower: an unregister that actually fails. + The errand's exit status is load-bearing: `unregister()` only logs a refusal and the script discards the output, so without it a login item macOS would not retract stayed behind — pointing at a bundle deleted moments later, unreachable diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 15a3903..df323c0 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -889,6 +889,18 @@ task gui:build && open dist/Dezhban.app confirm the log says it will retry. Then fix the bundle and launch again — the agent must register. `defaults read com.behnam-rk.dezhban.app dezhban.loginItemMigratedToAgent` must be absent or 0 between the two. +- [ ] **An awaiting-approval registration is explained on a fresh pane open.** With + the agent registered but switched off under System Settings → General → Login + Items, quit Dezhban and reopen Settings *without touching the switch*. It reads + ON (an awaiting-approval registration counts as registered), so the line + explaining that must be there unprompted — nothing else can express the + difference. +- [ ] **The uninstall errand does not cry wolf.** On an install where + login-at-launch was never switched on, run + `Dezhban.app/Contents/MacOS/DezhbanMenu --unregister-login-item; echo $?` — it + must be **0**. `SMAppService` reports `.notFound` for an agent that was never + registered, not only for a plist it cannot resolve, so anything treating that + status as "cannot tell" warns on every ordinary uninstall. - [ ] **The approval prompt's own guidance survives it.** Turn the login item off *in System Settings*, then switch Dezhban's "Open this app at login" on: the line says macOS is holding it for your approval. Click away and back **without diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index de6139e..891cf7c 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -206,15 +206,20 @@ enum LoginItem { /// because a pre-upgrade user never set it. private static var legacyEnabled: Bool { SMAppService.mainApp.status == .enabled } - /// The agent's plist cannot be resolved, so nothing can be asserted about a - /// registration an earlier, valid copy of the bundle may have left in launchd. - /// - /// `registered()` reads `.notFound` as "not registered", which is the honest - /// answer about *this* bundle's plist and the wrong one for "is there anything - /// to retract" — `retractAll()` returned true on it, so the uninstall errand - /// exited 0 and the script printed a clean removal over precisely the orphan it - /// exists to remove. - private static var agentUnresolvable: Bool { service.status == .notFound } + // There is deliberately no "the plist could not be resolved" predicate, and one + // was tried and removed. `.notFound` looks like it carries that meaning, and on + // this OS it is simply what an agent that was never registered reports — + // verified against the shipped ad-hoc bundle, which exits the retraction errand + // as a failure with nothing registered at all. Treating it as "cannot tell" + // therefore fired on the common path: `disable()` snapped the switch back ON with + // "macOS would not remove the login item" over a retraction that had just + // succeeded, and every uninstall of an install where login-at-launch was never + // switched on warned that macOS had refused to retract it — crying wolf on the + // ordinary path and eroding the signal on the one the warning was written for. + // + // So the distinction is not available from a status read, and nothing pretends + // otherwise. What remains truthful is an unregister that actually fails, which is + // what `registered()` reports afterwards. /// Whether a registration exists at all, as opposed to one that will start /// the app *right now*. @@ -439,14 +444,7 @@ enum LoginItem { // on was refused, permanently, with nothing having warned them. return liveOutcome(.disabling, fallback: .blockedByLegacy) } - if registered(service) || agentUnresolvable { - // `agentUnresolvable` too: reporting "App will not open at login" when - // the plist could not even be resolved claims a retraction that was - // never attempted, over a registration an earlier copy of the bundle may - // still hold. - return .agentStuck - } - return .disabled + return registered(service) ? .agentStuck : .disabled } /// Retracts everything that could start this app at login, best effort. @@ -472,11 +470,6 @@ enum LoginItem { // unregistered, files deleted". The orphan the errand exists to remove, // now silent. The exit status is what makes it visible. // - // An unresolvable plist counts as a failure, not a success: it means - // nothing could be asserted either way, and claiming a clean retraction - // over a registration that may well survive is the one direction this - // must not err in. - if agentUnresolvable { return false } return !registered(service) && !registered(.mainApp) } } diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index cce530f..03e331e 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -1053,6 +1053,19 @@ struct SettingsView: View { loginMessageForEnabled = nil loginMessageAwaitsApproval = false } + // And *state* it, not only preserve it. This read was only ever + // consulted to decide whether to clear a message a click had left + // behind — so opening the pane with no preceding click, on an install + // whose agent the user had switched off under Login Items, painted the + // switch ON with nothing said, while nothing started the app at login. + // `enabled` counts an awaiting-approval registration, so the switch + // cannot express it; this is the only thing that can. + if live.awaitingApproval, loginMessage == nil { + loginMessage = LoginItem.Outcome.awaitingApproval.message + loginMessageIsTransient = false + loginMessageAwaitsApproval = true + loginMessageForEnabled = live.enabled + } } } notifyPrefs = NotificationManager.prefs From 60554088255b6e6864d33490184c3fe4e9b05065 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 07:51:37 +0330 Subject: [PATCH 29/36] fix(gui): identify hand-off requests instead of timing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-eighth review round, four findings. The debounce is gone. Three versions of it tried to tell "two signals for one request" from "two requests" using elapsed time plus a definite/indefinite flag, and every one both let a duplicate window through in one ordering and swallowed a genuine second launch in another — because elapsed time does not carry that information. Each request now carries a token, written into the file and repeated in the notification, so whichever signal arrives second is recognised and dropped while a new launch always opens. That deletes lastHandoffOpenAt, lastHandoffOpenWasDefinite, handoffDebounce and the definite parameter, and the two remaining failures this round named — a fileless pair always counting as indefinite, and the pair-closing branch consuming a real new request — cannot be expressed any more. The migration's "user turned this off" guard sat above the legacy block, so it short-circuited the retraction that the comment twenty lines below insists is unconditional. It gates the register only now. A live mainApp registration left on file is re-armed by the user approving the single Dezhban row under Login Items and then starts the app with no marker, permanently, since the migration is marked done and never runs again. That was reachable because disable() wrote the account-wide flag from any bundle: a dev build or a ~/Downloads copy switching login-at-launch off recorded "off" for the *installed* app, whose migration then skipped its retraction forever, while this bundle's own retraction acted on something else. Retracting from anywhere is fine; speaking for the account is not. post()'s nil case defaulted to success, so a nil request path would have sent a notification without the fileless marker and dropped the launch with nothing logged. Unreachable today, and the safe reading of "no file to write" is the same as a failed write. And the uninstaller's bundle search was depth-limited while isInStableInstallLocation accepts any depth under an Applications directory — so an install the app would register the login agent from was one the uninstaller could not find, printing "the app is gone" over a bundle still launching at every login. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 8 ++ docs/contribute/testing.md | 6 +- .../Sources/DezhbanCore/HandoffRequest.swift | 28 ++++-- .../Sources/DezhbanMenu/AppDelegate.swift | 97 ++++++++----------- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 32 ++++-- gui/macos/Sources/DezhbanMenu/main.swift | 23 ++++- .../HandoffRequestTests.swift | 27 +++++- packaging/macos/uninstall.sh | 8 +- 8 files changed, 143 insertions(+), 86 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 88b454a..362dfb6 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -183,6 +183,14 @@ with an argument, the pre-`SMAppService` pattern. Debouncing what the user notices is cheaper and safer than making two asynchronous signals agree. + Which of the two signals is acting is settled by identity, not by timing. Each + request carries a token, written into the file and repeated in the notification, so + whichever signal arrives second is recognised as describing a request already + answered while a genuinely new launch — new token — always opens. Three earlier + versions inferred this from elapsed time plus a definite/indefinite flag, and every + one of them both let a duplicate window through in one ordering and swallowed a real + second launch in another, because elapsed time does not carry that information. + Only the ambiguous ones, though. A claim of `.fresh` means the caller took a request nobody else had, so it is a distinct launch by definition — two double-clicks a second apart with the window closed in between are two requests diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index df323c0..54a3498 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -828,9 +828,9 @@ task gui:build && open dist/Dezhban.app out and back in, then double-click the app twice in quick succession as early as you can, closing the window (⌘W) in between. Both must open. Best-effort by nature: the deterministic coverage is - `HandoffRequestTests.anOverlappingClaimerIsToldItLost` and the `definite` - split in `openForHandoff`, which is what stops the debounce swallowing a real - second request. + `HandoffRequestTests.anOverlappingClaimerIsToldItLost` and + `theClaimCarriesThePostedToken`, since it is the token — not any timing rule — + that tells the two signals for one request from two requests. - [ ] **A hand-off that arrives before the app is observing still works.** The race the `HandoffRequest` file exists for: log out and back in and double-click the app in `/Applications` as early as you can, while it is diff --git a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift index 37578e7..cbad5ea 100644 --- a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift +++ b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift @@ -36,7 +36,7 @@ public struct HandoffRequest { HandoffRequest(url: lock.deletingPathExtension().appendingPathExtension("handoff")) } - /// Records a request, reporting whether it landed. + /// Records a request under `token`, reporting whether it landed. /// /// Still best effort — a failure must never stop the losing process from /// exiting — but not silent. If this fails while the incumbent is between @@ -45,9 +45,9 @@ public struct HandoffRequest { /// silent no-op the mechanism was written to prevent. The caller cannot repair /// that, but it can say so. @discardableResult - public func post() -> Result { + public func post(token: String) -> Result { do { - try Data().write(to: url, options: .atomic) + try Data(token.utf8).write(to: url, options: .atomic) return .success(()) } catch { return .failure(error) @@ -65,9 +65,18 @@ public struct HandoffRequest { /// starting is precisely the "user impatient with a slow start" case this whole /// mechanism is written around, and the cutoff threw that request away. public enum Claim: Equatable { - /// Taken. Because the session owner discards whatever it finds when it takes - /// the lock, a request seen after that was written by a live duplicate. - case fresh + /// Taken, carrying the token the poster wrote. Because the session owner + /// discards whatever it finds when it takes the lock, a request seen after + /// that was written by a live duplicate. + /// + /// The token is what makes "two signals for one request" distinguishable + /// from "two requests" *exactly*. Three earlier attempts inferred it from + /// timing and a definite/indefinite flag, and each one both let a duplicate + /// window through and swallowed a genuine second launch, because elapsed + /// time cannot tell those apart. Identity can: the notification carries the + /// same token, so whichever signal arrives second is recognised and dropped + /// while a new launch — new token — always opens. + case fresh(token: String?) /// There was nothing to take. case absent /// There was a request, and somebody else took it first. Whoever did is @@ -111,6 +120,11 @@ public struct HandoffRequest { /// in a comment rather than a test would be asserting the whole point. public func claim(interleaved: () -> Void = {}) -> Claim { guard FileManager.default.fileExists(atPath: url.path) else { return .absent } + // Read before the unlink, since the unlink is what claims it. A request + // written by an older build carries no token; `nil` then means "cannot + // dedupe this one", which the caller treats as its own identity rather than + // as a match. + let token = (try? Data(contentsOf: url)).flatMap { String(data: $0, encoding: .utf8) } interleaved() do { try FileManager.default.removeItem(at: url) @@ -127,6 +141,6 @@ public struct HandoffRequest { } return .blocked(error.localizedDescription) } - return .fresh + return .fresh(token: token?.isEmpty == false ? token : nil) } } diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index fe95f1a..f2dafe4 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -22,11 +22,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var updateTimer: Timer? /// Runs only for a few seconds after launch — see `startHandoffBackstop`. private var handoffTimer: Timer? - /// When a hand-off last opened the window, so two signals for one request - /// cannot open it twice — see `openForHandoff`. - private var lastHandoffOpenAt: Date? - /// Whether that open came from a definitive claim. See `openForHandoff`. - private var lastHandoffOpenWasDefinite = false + /// The last hand-off request acted on, so the two signals for one request + /// cannot open the window twice — see `openForHandoff`. + private var lastHandoffToken: String? private var snapshot: Snapshot? private var lastMtime: Date? private var lastIconKey: String? @@ -51,6 +49,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// here rather than inferred by the receiver from a timer. static let handoffFilelessKey = "dezhban.handoffFileless" + /// Identifies the request, so the notification and the file-watcher can tell + /// "the other signal for the request I already handled" from "a new launch". + static let handoffTokenKey = "dezhban.handoffToken" + func applicationDidFinishLaunching(_: Notification) { // FIRST, and only for the session owner. A duplicate posts its hand-off as // it exits and then dies; the notification is delivered immediately and @@ -151,14 +153,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Either the launch window, or the poster telling us its file never landed. let fileless = (note.userInfo?[Self.handoffFilelessKey] as? String) == "1" let acceptWithoutFile = handoffTimer != nil || fileless + // The poster's token, so a signal with no file to read still identifies its + // request and cannot be mistaken for a second launch — or for the same one. + let postedToken = note.userInfo?[Self.handoffTokenKey] as? String DispatchQueue.global(qos: .userInitiated).async { [weak self] in switch sessionHandoff?.claim() ?? .absent { - case .fresh: + case .fresh(let token): // A file, taken by us. The session owner discards whatever it finds // when it takes the lock, so a file seen afterwards was written by a // live duplicate however long ago — which is what makes a slow // launch work rather than being thrown away for being slow. - DispatchQueue.main.async { self?.openForHandoff(definite: true) } + DispatchQueue.main.async { self?.openForHandoff(token: token ?? postedToken) } case .absent: // No file to point at, so nothing here proves a duplicate of this // app wrote it. Accepted only while the launch-time backstop is @@ -181,7 +186,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // `open -a Dezhban` — and without it a full or read-only home turned // the hand-off into the silent no-op it exists to prevent. guard acceptWithoutFile else { return } - DispatchQueue.main.async { self?.openForHandoff(definite: false) } + DispatchQueue.main.async { self?.openForHandoff(token: postedToken) } case .lost: // The backstop got there first and is opening the window. break @@ -191,63 +196,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // every tick), but this handler is a one-shot event tied to a real // user launch: standing down here would make that launch the silent // no-op the mechanism exists to prevent, and every launch after it. - // Definite, not indefinite. There is no other claimant to pair - // with — the backstop stands down on `.blocked` — so treating it as - // ambiguous only meant the debounce dropped the *second* of two - // launches inside three seconds, and, since a request that cannot be - // removed stays that way, every later pair too. NSLog("DezhbanMenu: hand-off request could not be claimed: \(why)") - DispatchQueue.main.async { self?.openForHandoff(definite: true) } + DispatchQueue.main.async { self?.openForHandoff(token: postedToken) } } } } - /// Opens the window for a hand-off. + /// Opens the window for a hand-off, once per request. /// - /// The claim in `HandoffRequest` settles who *owns* a request; this settles the - /// residue, which the claim cannot: the two signals for one request can pass - /// each other such that both legitimately conclude they should act. Debouncing - /// the effect is cheaper and safer than trying to make two asynchronous signals - /// agree — and the effect is what the user notices, since `MainWindow.open()` - /// calls `NSApp.activate(ignoringOtherApps:)`, so a duplicate is a second focus - /// steal or a window reopening just after they closed it. + /// `token` identifies the request. The two signals for one request — the + /// notification and the launch-time backstop — carry the same one, so whichever + /// arrives second is recognised and dropped; a genuinely new launch carries a + /// new token and always opens. That distinction has to be exact, because the + /// effect is what the user notices: `MainWindow.open()` activates the app, so a + /// duplicate is a second focus steal or a window reopening just after they + /// closed it, while a dropped one is the silent no-op the whole mechanism exists + /// to prevent. /// - /// `definite` is what keeps the debounce from swallowing real work. A claim of - /// `.fresh` means this caller took a request nobody else had, so it is a - /// distinct launch by definition — two double-clicks a second apart, with the - /// window closed in between, are two requests and must both be answered. - /// Debouncing those on elapsed time alone made the second one the silent no-op - /// this whole mechanism exists to prevent. Only the ambiguous signals, which - /// may be describing a request another caller already handled, are debounced. - private func openForHandoff(definite: Bool) { - let now = Date() - if let last = lastHandoffOpenAt, now.timeIntervalSince(last) < Self.handoffDebounce { - // An indefinite open never repeats inside the window. - if !definite { return } - // A definitive one is suppressed only when the open it would follow was - // *indefinite* — that pairing is the two signals for a single request - // (the backstop claimed `.fresh` while the notification saw `.absent`, - // and the `.absent` hop reached main first). Two genuine double-clicks - // both claim `.fresh`, so a definite open never suppresses another - // definite one, which is what keeps a real second request from being - // swallowed. - if !lastHandoffOpenWasDefinite { - // And that pair is now closed. Leaving the flag false meant the rest - // of the 3s window kept swallowing definite opens, so a genuine - // second double-click a second later was dropped — the no-op this - // exists to prevent, reached through the machinery preventing it. - lastHandoffOpenWasDefinite = true - return - } - } - lastHandoffOpenAt = now - lastHandoffOpenWasDefinite = definite + /// Identity rather than timing, after three attempts at timing. A debounce with + /// a definite/indefinite flag could not tell a paired signal from a new request + /// — elapsed time does not carry that information — so every version of it both + /// let a duplicate window through in one ordering and swallowed a real second + /// launch in another. + /// + /// A `nil` token cannot be matched (a request from an older build, or a poster + /// whose file never landed), so it is always acted on: an extra window is the + /// lesser failure. + private func openForHandoff(token: String?) { + if let token, token == lastHandoffToken { return } + lastHandoffToken = token MainWindow.shared.open() } - /// Long enough to cover the gap between a notification and a backstop tick - /// (0.5s), short enough that two genuinely separate launches both get a window. - private static let handoffDebounce: TimeInterval = 3 /// Installs both hand-off consumers. Called only by the session owner. /// @@ -294,16 +274,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Only `.fresh`. `.lost` means the notification handler claimed it and // is already opening the window; `.absent` is the ordinary case of // there being no request at all, which is what almost every tick sees. + var claimed: String? switch handoff.claim() { - case .fresh: - break + case .fresh(let token): + claimed = token case .blocked(let why): NSLog("DezhbanMenu: hand-off request could not be claimed: \(why)") return case .absent, .lost: return } - DispatchQueue.main.async { self?.openForHandoff(definite: true) } + DispatchQueue.main.async { self?.openForHandoff(token: claimed) } } } diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 891cf7c..59b61ca 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -412,7 +412,16 @@ enum LoginItem { } private static func disable() -> Outcome { - UserDefaults.standard.set(true, forKey: userDisabledKey) + // The flag is account-wide (a shared UserDefaults domain), so only a copy + // that will still be here may set it. A dev build or a ~/Downloads copy + // switching login-at-launch off would otherwise record "the user turned this + // off" for the *installed* app, whose migration then skips its retraction + // forever — while this bundle's own `retractLegacy()` acted on something + // else entirely. Retracting from anywhere is fine; speaking for the account + // is not. + if isInStableInstallLocation { + UserDefaults.standard.set(true, forKey: userDisabledKey) + } // Flushed before the unregister below, because that unregister may get // this process killed: launchd terminates a loaded job's running process, // and in a login-started session that process is the app (recorded as an @@ -504,13 +513,7 @@ enum LoginItem { + "(\(Bundle.main.bundleURL.path)); the copy in /Applications will do it") return } - // An explicit "off" outlives every retry below. Without this, a migration - // allowed to retry would re-register what the user had switched off — the - // bug the persisted flag was introduced to kill. - guard !UserDefaults.standard.bool(forKey: userDisabledKey) else { - markMigrated() - return - } + let userDisabled = UserDefaults.standard.bool(forKey: userDisabledKey) if registered(.mainApp) { // Whether it was *enabled* decides what happens afterwards — that is @@ -575,6 +578,19 @@ enum LoginItem { // gone — this launch, or an earlier one that was killed by the unload before // it could finish. + // An explicit "off" outlives every retry: without this, a migration allowed + // to retry would re-register what the user had switched off. It gates the + // *register* only. Sitting above the legacy block, as it first did, it also + // short-circuited the retraction — which the code twenty lines up insists is + // unconditional, and for a reason: a live `mainApp` registration left on file + // is re-armed by the user approving the single "Dezhban" row under Login + // Items, and then starts the app with no marker, permanently, because the + // migration is marked done and never runs again. + guard !userDisabled else { + markMigrated() + return + } + // Reached with the legacy item confirmed gone — now, or on an earlier // launch whose register() failed. if registered(service) { diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index ab767d4..f4d7877 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -174,10 +174,23 @@ func acquireSessionOwnership() -> SessionLock? { // with no observer installed — the file is the one that waits, and the // incumbent's launch-time backstop finds it. Whichever of the two gets // there claims it, so the window opens once (see HandoffRequest). - var fileLanded = true - if case .failure(let error) = sessionHandoff?.post() ?? .success(()) { + // One token for both signals, so the incumbent can recognise the second + // one as describing a request it has already answered — and a genuinely + // new launch, with a new token, as a new request. + let token = UUID().uuidString + // Defaults to NOT landed. A nil `sessionHandoff` is unreachable today + // (it is assigned immediately above the acquire) but the safe reading of + // "there was no file to write" is the same as a failed write: say so, so + // the notification carries the fileless marker. Defaulting to success + // meant a future reordering would drop a launch with nothing logged. + var fileLanded = false + switch sessionHandoff?.post(token: token) { + case .success: + fileLanded = true + case .failure(let error): NSLog("DezhbanMenu: could not record the hand-off request: \(error)") - fileLanded = false + case nil: + NSLog("DezhbanMenu: no hand-off request path; relying on the notification") } // Whether the file landed travels WITH the notification, because this // process is the only one that knows. The incumbent requires a file @@ -191,7 +204,9 @@ func acquireSessionOwnership() -> SessionLock? { DistributedNotificationCenter.default().postNotificationName( NSNotification.Name(AppDelegate.openWindowNotification), object: Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path, - userInfo: fileLanded ? nil : [AppDelegate.handoffFilelessKey: "1"], + userInfo: fileLanded + ? [AppDelegate.handoffTokenKey: token] + : [AppDelegate.handoffTokenKey: token, AppDelegate.handoffFilelessKey: "1"], deliverImmediately: true) } NSLog("DezhbanMenu: another copy of this install owns the session; exiting") diff --git a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift index 8d7aeaf..25b21b5 100644 --- a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift @@ -17,8 +17,8 @@ struct HandoffRequestTests { defer { try? FileManager.default.removeItem(at: dir) } let request = HandoffRequest(url: dir.appendingPathComponent("a.handoff")) - request.post() - #expect(request.claim() == .fresh) + request.post(token: "t1") + #expect(request.claim() == .fresh(token: "t1")) #expect(request.claim() == .absent) } @@ -38,11 +38,28 @@ struct HandoffRequestTests { defer { try? FileManager.default.removeItem(at: dir) } let request = HandoffRequest(url: dir.appendingPathComponent("e.handoff")) - request.post() + request.post(token: "t1") request.discard() #expect(request.claim() == .absent) } + /// The token is what lets the two signals for one request be told apart from two + /// requests. Timing could not, which is why three attempts at a debounce each + /// both duplicated a window and swallowed a real launch. + @Test func theClaimCarriesThePostedToken() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let request = HandoffRequest(url: dir.appendingPathComponent("i.handoff")) + + request.post(token: "abc-123") + #expect(request.claim() == .fresh(token: "abc-123")) + + // A request from an older build carries nothing; `nil` means "cannot dedupe", + // which the caller treats as its own identity rather than as a match. + try Data().write(to: request.url) + #expect(request.claim() == .fresh(token: nil)) + } + /// Two claimers overlapping on one request — the notification handler and the /// launch-time backstop, which is the pair this type exists to arbitrate. The /// loser must be told it lost, not that there was nothing there: only that @@ -53,7 +70,7 @@ struct HandoffRequestTests { defer { try? FileManager.default.removeItem(at: dir) } let request = HandoffRequest(url: dir.appendingPathComponent("f.handoff")) - request.post() + request.post(token: "t1") // Stands in for the other claimer winning between this one's stat and its // remove — the only way `.lost` can arise, and the reason `claim` takes // the hook. @@ -74,7 +91,7 @@ struct HandoffRequestTests { let dir = try tempDir() defer { try? FileManager.default.removeItem(at: dir) } let request = HandoffRequest(url: dir.appendingPathComponent("h.handoff")) - request.post() + request.post(token: "t1") // Read-only parent: the file is visible but cannot be unlinked. try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: dir.path) diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index f938491..30da2a7 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -111,7 +111,13 @@ fi if [ ! -d "$APP" ]; then for root in /Applications "${CONSOLE_HOME:+$CONSOLE_HOME/Applications}"; do [ -d "$root" ] || continue - found=$(find "$root" -maxdepth 3 -name Dezhban.app -type d -print 2>/dev/null | head -1) + # Unbounded depth, to match LoginItem.isInStableInstallLocation, which accepts + # anything *under* an Applications directory. A depth limit here meant an + # install the app would happily register the login agent from — say + # /Applications/Utilities/Network/Tools/Dezhban.app — was one the uninstaller + # could not find, so it printed "Nothing will start Dezhban (the app is gone)" + # over a bundle still sitting there launching at every login. + found=$(find "$root" -name Dezhban.app -type d -print 2>/dev/null | head -1) if [ -n "$found" ]; then APP="$found" echo "note: found the app at $APP" >&2 From 5b9472e47b2c55584c267c53632e0c8fc875ccae Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 08:22:50 +0330 Subject: [PATCH 30/36] fix(gui): one status read per decision, and claim by rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-ninth review round, five findings, all low. `state` and `liveOutcome` each queried SMAppService two or three times and derived a decision from the mix, so a status changing between reads selected a branch matching neither instant. The reachable case: a user approving Dezhban in System Settings while the pane read it, getting "macOS is holding this for your approval" written under a switch painted OFF. One read now feeds every branch, which also makes .agentStuck unreachable from the enabling direction — it tells the user to go and *remove* the login item, the opposite of what they asked for, and it was only ever reachable through that same split read. claim() read the token before unlinking, which is a TOCTOU on a claim-by-unlink design: post() is an atomic replace, so a second duplicate's request landing between the read and the unlink had this caller delete T2 while reporting T1 — the token already handled, so the caller matched it, declined to open, and that launch became a silent no-op with its request gone. Renaming aside is the claim and the read barrier at once. Reports of a failed retraction rested on a status read taken microseconds after unregister(), which is not documented to update synchronously. If it lags, disable() snaps the switch back ON over a retraction that succeeded and the uninstall errand exits non-zero so the script warns over a clean removal — the crying-wolf failure the .notFound predicate was deleted for two rounds ago, reached from the other side. A refusal is now confirmed over ~150ms before being reported; a success still returns on the first read. And build-app.sh asserted four of the six facts of its class: BundleProgram against the executable actually installed, and the bundle identifier across Info.plist, the agent's AssociatedBundleIdentifiers and uninstall.sh's APP_BUNDLE_ID, were unchecked — so renaming the executable target or the bundle id passed every test and this build while the login job failed to load, the Login Items row lost its name, and uninstall left the migration flag behind. Both new checks are verified to fail the build when broken. One process note: the first attempt at these build assertions was lost to an interrupted command, and the negative test I ran afterwards is what caught that they were never written. Worth remembering that a green build proves nothing about an assertion you have not seen fail. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/DezhbanCore/HandoffRequest.swift | 24 +++++--- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 57 ++++++++++++++++--- gui/macos/build-app.sh | 24 ++++++++ 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift index cbad5ea..53c35c4 100644 --- a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift +++ b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift @@ -120,16 +120,24 @@ public struct HandoffRequest { /// in a comment rather than a test would be asserting the whole point. public func claim(interleaved: () -> Void = {}) -> Claim { guard FileManager.default.fileExists(atPath: url.path) else { return .absent } - // Read before the unlink, since the unlink is what claims it. A request - // written by an older build carries no token; `nil` then means "cannot - // dedupe this one", which the caller treats as its own identity rather than - // as a match. - let token = (try? Data(contentsOf: url)).flatMap { String(data: $0, encoding: .utf8) } interleaved() + // Claimed by *renaming* it aside, then read from the renamed copy. Reading + // first and unlinking after was a TOCTOU on a claim-by-unlink design: a + // second duplicate's `post()` is an atomic replace, so one landing between + // the read and the unlink had this caller delete request T2 while reporting + // token T1 — and T1 is the token already handled, so the caller matched it, + // declined to open, and that second launch became a silent no-op with its + // request gone. The rename is the claim and the read barrier at once. + // + // A request written by an older build carries no token; `nil` then means + // "cannot dedupe this one", which the caller treats as its own identity + // rather than as a match. + let claimed = url.deletingLastPathComponent() + .appendingPathComponent(".claiming-\(UUID().uuidString)") do { - try FileManager.default.removeItem(at: url) + try FileManager.default.moveItem(at: url, to: claimed) } catch let error as NSError { - // Gone between the check and the remove is the benign case: somebody + // Gone between the check and the rename is the benign case: somebody // else claimed it and is acting on it. Anything else is a real failure // and must not wear the same face — see `Claim.blocked`. if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { @@ -141,6 +149,8 @@ public struct HandoffRequest { } return .blocked(error.localizedDescription) } + let token = (try? Data(contentsOf: claimed)).flatMap { String(data: $0, encoding: .utf8) } + try? FileManager.default.removeItem(at: claimed) return .fresh(token: token?.isEmpty == false ? token : nil) } } diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 59b61ca..827c5fb 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -233,7 +233,20 @@ enum LoginItem { /// the registration still on file, which is the orphan the errand exists to /// remove. private static func registered(_ target: SMAppService) -> Bool { - switch target.status { + isRegistered(target.status) + } + + /// The same question asked of a status already in hand. + /// + /// Every decision that needs more than one fact about the agent takes *one* + /// status read and derives them all from it. Chaining `registered(service)`, + /// `service.status == .requiresApproval` and `agentEnabled` issued three separate + /// XPC queries of a mutable value, so a change between the first and the last + /// selected a branch matching neither instant — the reachable case being a user + /// approving Dezhban in System Settings while the pane read it, getting "macOS is + /// holding this for your approval" written under a switch painted OFF. + private static func isRegistered(_ status: SMAppService.Status) -> Bool { + switch status { case .notRegistered, .notFound: return false default: return true } @@ -282,7 +295,8 @@ enum LoginItem { /// off for. static var state: (enabled: Bool, awaitingApproval: Bool) { queue.sync { - (registered(service) || legacyEnabled, service.status == .requiresApproval) + let agent = service.status + return (isRegistered(agent) || legacyEnabled, agent == .requiresApproval) } } @@ -453,7 +467,7 @@ enum LoginItem { // on was refused, permanently, with nothing having warned them. return liveOutcome(.disabling, fallback: .blockedByLegacy) } - return registered(service) ? .agentStuck : .disabled + return stillRegistered(service) ? .agentStuck : .disabled } /// Retracts everything that could start this app at login, best effort. @@ -479,7 +493,7 @@ enum LoginItem { // unregistered, files deleted". The orphan the errand exists to remove, // now silent. The exit status is what makes it visible. // - return !registered(service) && !registered(.mainApp) + return !stillRegistered(service) && !stillRegistered(.mainApp) } } @@ -719,16 +733,21 @@ enum LoginItem { /// `fallback` is used only when nothing is live at all. private static func liveOutcome(_ direction: Direction, fallback: Outcome) -> Outcome { if legacyEnabled { return .legacyStuck } + let agent = service.status switch direction { case .enabling: - if agentEnabled { return .enabled } - if service.status == .requiresApproval { return .awaitingApproval } - if registered(service) { return .agentStuck } + // No `.agentStuck` here: it tells the user to go and *remove* the login + // item, which is the opposite of what somebody enabling asked for. The + // two registered statuses are both answered above it, so it was only ever + // reachable by the status changing between two of the three reads this + // used to take. + if agent == .enabled { return .enabled } + if agent == .requiresApproval { return .awaitingApproval } case .disabling: // Anything still registered after a disable is a retraction that failed, // whatever status it wears. `.awaitingApproval` in particular would tell // someone who clicked *off* to go and enable Dezhban. - if registered(service) { return .agentStuck } + if isRegistered(agent) { return .agentStuck } } return fallback } @@ -745,6 +764,28 @@ enum LoginItem { } } + /// Whether `target` is still registered, giving the status a moment to catch up. + /// + /// `SMAppService.status` is not documented to update synchronously with + /// `unregister()`, and a single read taken microseconds afterwards is what every + /// "the retraction failed" report rests on. If it lags, `disable()` snaps the + /// switch back ON with "macOS would not remove the login item" over a retraction + /// that succeeded, and the uninstall errand exits non-zero so the script prints + /// its refusal warning over a clean removal — the crying-wolf failure the + /// `.notFound`-means-"cannot tell" predicate was deleted for, reached from the + /// other side. + /// + /// Bounded to ~150ms and only ever on the way to reporting a *refusal*, which is + /// the rare path; a success is returned on the first read. This runs on the + /// mutation queue, never the main thread. + private static func stillRegistered(_ target: SMAppService) -> Bool { + for _ in 0 ..< 3 { + if !registered(target) { return false } + usleep(50_000) + } + return registered(target) + } + private static func unregister(_ target: SMAppService, what: String) { do { try target.unregister() diff --git a/gui/macos/build-app.sh b/gui/macos/build-app.sh index 5ada820..f0be54a 100755 --- a/gui/macos/build-app.sh +++ b/gui/macos/build-app.sh @@ -132,6 +132,30 @@ if ! grep -qxF "LOGIN_AGENT=$AGENT_LABEL" "$REPO_ROOT/packaging/macos/uninstall. echo "build-app.sh: uninstall.sh does not set LOGIN_AGENT to '$AGENT_LABEL' — it would fail to retract the registration it is meant to remove" >&2 exit 1 fi +# 4. BundleProgram must name the executable this script actually installs. It is +# bundle-relative and launchd resolves it at load time, so renaming the SwiftPM +# executable target passes go test, swift test and this build while the login job +# fails to load — surfacing only as a status nobody reads. +agent_program="$(plutil -extract BundleProgram raw -o - "$AGENT_PLIST")" +if [[ ! -x "$APP/$agent_program" ]]; then + echo "build-app.sh: LoginAgent.plist BundleProgram is '$agent_program', which is not an executable in the assembled bundle — launchd would fail to load the login job" >&2 + exit 1 +fi +# 5. The bundle identifier has to agree in three places: Info.plist (what macOS +# knows the app as), the agent's AssociatedBundleIdentifiers (what makes the +# Login Items row read "Dezhban" rather than a raw job label), and uninstall.sh's +# APP_BUNDLE_ID (what removes the per-user leftovers, including the flag that +# would otherwise make a later reinstall skip the login-item migration). +bundle_id="$(plutil -extract CFBundleIdentifier raw -o - "$APP/Contents/Info.plist")" +agent_assoc="$(plutil -extract AssociatedBundleIdentifiers.0 raw -o - "$AGENT_PLIST")" +if [[ "$agent_assoc" != "$bundle_id" ]]; then + echo "build-app.sh: LoginAgent.plist AssociatedBundleIdentifiers is '$agent_assoc' but the bundle identifier is '$bundle_id' — the Login Items entry would not be attributed to Dezhban" >&2 + exit 1 +fi +if ! grep -qxF "APP_BUNDLE_ID=$bundle_id" "$REPO_ROOT/packaging/macos/uninstall.sh"; then + echo "build-app.sh: uninstall.sh does not set APP_BUNDLE_ID to '$bundle_id' — it would leave the per-user preferences behind, and a later reinstall would skip the login-item migration" >&2 + exit 1 +fi # Documentation, rendered from the repo's own markdown into the bundle. Shipping # it means the help pane works with every byte of egress cut — which is exactly From 1a150352cf76b489c5ce2799a8b3b2d463499101 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 08:41:22 +0330 Subject: [PATCH 31/36] fix(gui): retry every post-unregister read, not two of the four MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirtieth review round, two findings — and the first is last round's fix applied to half its sites. stillRegistered() was introduced because SMAppService.status is not documented to keep up with unregister(), and then wired into only disable()'s agent check and retractAll(). The three legacy checks kept their single un-retried reads, and the worst of them is in migrateLocked(): on an upgrading account with login-at-launch ON, a status lagging past that read logged "the legacy login item could not be retracted", called markMigrated() and returned — so the agent was never registered, and because the flag is now set, the register-retry whose whole justification is "nothing starts the app at login, and with the flag set that would never be retried" can never run. Nothing starting the app at login, permanently, reported in an NSLog. All three go through the retried read now; the pre-checks stay single reads, since nothing has happened yet for a status to lag behind. disable() gated only its account-wide flag write on the install location, leaving the retraction itself ungated. The agent is registered under a label every copy of the app shares, so a dev build shows the switch ON because the *installed* copy registered the agent, and one click off retracts the installed copy's registration — silently, because the "off" is not recorded from an unstable location either, so the installed app's migration never sees that the user asked for it. No legitimate registration originates outside an Applications directory, so there is nothing there for such a copy to turn off, and it now refuses exactly as enable() does. The checklist gains the disable direction, which it only covered for enable. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 18 ++++++++- docs/contribute/testing.md | 14 +++++-- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 38 +++++++++++++------ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 362dfb6..23fad27 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -376,7 +376,15 @@ with an argument, the pre-`SMAppService` pattern. been one, marked the account migrated and returned. Nothing starting the app at login, permanently, which is the hole the flag exists to close. Recording the attempt also keeps a failed retraction re-attemptable, since the caller re-reads - the live status either way. + the live status either way — and every read taken *after* an unregister is a + retried one. `SMAppService.status` is not documented to keep up with + `unregister()`, and a single read microseconds later is what every "the retraction + failed" report rests on: a lagging status on an upgrading account with + login-at-launch on logged "could not be retracted", marked the account migrated + and returned, so the agent was never registered and the register-retry that exists + for precisely that outcome could never run. The pre-checks — "is there anything to + retract at all" — stay single reads, since nothing has happened yet for a status + to lag behind. The same discipline applies to the fact that *decides* what happens next. A pre-upgrade user who switched Dezhban off under System Settings leaves `mainApp` at @@ -407,6 +415,14 @@ with an argument, the pre-`SMAppService` pattern. asking the presence question, because a `.requiresApproval` registration is still a registration to retract. + Disabling it is gated the same way, and for the sharper version of the reason: the + agent is registered under a *label* that every copy of the app shares along with + the bundle identifier, so a dev build shows the switch ON because the installed + copy registered the agent, and a click off would retract the installed copy's + registration — silently, since the account-wide "off" is not recorded from an + unstable location either. No legitimate registration originates outside an + Applications directory, so such a copy has nothing there to turn off. + Enabling the login item from Settings is gated on the install location too, not only the migration. The waiver it used to carry — "an explicit toggle is the user's own call" — missed that the consequence is not the user's to undo: `register()` diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 54a3498..f810e57 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -849,13 +849,21 @@ task gui:build && open dist/Dezhban.app Applications directory counts as a place the app will stay; comparing only the immediate parent left that user's legacy item running with no marker permanently, reported nowhere. -- [ ] **A copy run from outside /Applications cannot claim the login item at all.** - Run `dist/Dezhban.app` and switch "Open this app at login" on: it must refuse, - with the line naming where it is running from, and +- [ ] **A copy run from outside /Applications cannot claim *or release* the login + item.** Run `dist/Dezhban.app` and switch "Open this app at login" on: it must + refuse, with the line naming where it is running from, and `launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` must still fail. Only the registering bundle can ever retract a registration, so a dev build that claimed it would leave an orphan nothing can remove once `dist` is rebuilt. + + Then the other direction, which matters more because it is silent: with the + *installed* copy's login item **on**, run `dist/Dezhban.app` — the switch reads + ON, since the agent is registered under a label every copy shares — and click + it **off**. It must refuse, and `launchctl print + gui/$UID/com.behnam-rk.dezhban.app.login` must still succeed afterwards. A dev + build retracting the installed app's registration is bad enough; doing it + without even recording the user's "off" is worse. - [ ] **A copy run from outside /Applications does not migrate the login item.** Unzip `Dezhban-macos.app.zip` to `~/Downloads` on a Mac with a pre-agent install and login-at-launch on, run it once, quit. The legacy login item diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 827c5fb..5e50109 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -372,7 +372,7 @@ enum LoginItem { // nothing the app can do will make enabling this safe. See // `Outcome.legacyStuck`. retractLegacy() - if registered(.mainApp) { + if stillRegistered(.mainApp) { // Which outcome depends on what actually survived, not on the direction // clicked. A legacy item still *enabled* is starting the app at login, // so `.legacyStuck` (isOn true) is the true report; only a dormant @@ -426,16 +426,22 @@ enum LoginItem { } private static func disable() -> Outcome { - // The flag is account-wide (a shared UserDefaults domain), so only a copy - // that will still be here may set it. A dev build or a ~/Downloads copy - // switching login-at-launch off would otherwise record "the user turned this - // off" for the *installed* app, whose migration then skips its retraction - // forever — while this bundle's own `retractLegacy()` acted on something - // else entirely. Retracting from anywhere is fine; speaking for the account - // is not. - if isInStableInstallLocation { - UserDefaults.standard.set(true, forKey: userDisabledKey) + // Gated exactly as `enable()` is, and for the sharper version of the same + // reason. The agent is registered under a *label*, and every copy of the app + // shares it along with the bundle identifier — so a dev build shows the + // switch ON because the installed copy registered the agent, and one click + // off would retract the *installed* copy's registration. Worse than the + // enable case: it was silent, since the account-wide "off" is not recorded + // from an unstable location either, so the installed app's migration would + // not even see that the user had asked for this. + // + // No legitimate registration originates outside an Applications directory — + // `enable()` and the migration both refuse — so there is nothing here for + // such a copy to legitimately turn off. + guard isInStableInstallLocation else { + return .unstableLocation(Bundle.main.bundleURL.deletingLastPathComponent().path) } + UserDefaults.standard.set(true, forKey: userDisabledKey) // Flushed before the unregister below, because that unregister may get // this process killed: launchd terminates a loaded job's running process, // and in a login-started session that process is the app (recorded as an @@ -453,7 +459,7 @@ enum LoginItem { // marker, which is the state this function exists to clear. retractLegacy() if registered(service) { unregister(service, what: "login agent") } - if registered(.mainApp) { + if stillRegistered(.mainApp) { // The stuck path. Reported rather than worked around: registering the // agent alongside it would mean two launches at login, one with the // marker and one without, and whichever won the race would decide @@ -565,7 +571,15 @@ enum LoginItem { UserDefaults.standard.synchronize() } retractLegacy() - if registered(.mainApp) { + // The retried read, like every other post-unregister check. A single one + // taken microseconds after the unload — which `SMAppService.status` is + // not documented to keep up with — was the worst placement of this bug + // in the file: a lagging status on an upgrading account with + // login-at-launch ON logged "could not be retracted", marked the account + // migrated and returned, so the agent was never registered AND the + // register-retry that exists for exactly that outcome could never run. + // Nothing starting the app at login, permanently, in an NSLog. + if stillRegistered(.mainApp) { // Stuck. The agent is left unregistered rather than stacked on top // of a live legacy item — two launches at login, one with the marker // and one without. Only the user can clear it now. From 82d30689db9afa415b2c613bf7519f0b22033afb Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 09:04:24 +0330 Subject: [PATCH 32/36] fix(gui): settle the register-side read, and stop trusting a leftover request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty-first review round, five findings. stillRegistered() was the retried read for the unregister side; the register side had none, and took three separate status reads on top. A status that had not caught up with a register() that did not throw gave .failed — isOn false — so the switch snapped OFF saying "macOS did not keep the registration" while the agent was registered and the app would start at login: the same switch-versus-reality lie stillRegistered exists to kill, on the other side. Three reads also let the status change between them, producing the self-contradicting "Could not change the login item: the login item is enabled." One settled read now feeds every branch. The .blocked hand-off was acted on unconditionally. Since the claim is a rename, .blocked means the directory is unwritable — which is also why no poster could have written that file: it is a leftover, and discard() cannot clear it either. So every later notification reactivated the app, fresh token each time, window reopening indefinitely with no in-product recovery. A real launch in that state cannot write its file either, so it arrives with the fileless marker and is accepted on that basis instead. And the backstop now actually stands down as its comment claimed, rather than returning from one tick and logging the same permanent condition eleven times. The uninstaller searched only the console user's ~/Applications, and CONSOLE_HOME is resolved only when somebody is logged in there — so over ssh or at the login window an install in a ~/Applications was never found, nothing was deleted, and the script still printed "files deleted" and "Nothing will start Dezhban (the app is gone)". It searches every account's now. And the "switch moved" clear-clause wiped the message its own comment names as the motivating case: .unstableLocation says this copy may not touch the login item, which stays true however the switch reads — and on a dev build beside an installed copy the switch reads ON permanently, so the disagreement never resolved and the explanation went on every activation. Outcomes now say whether they describe the switch's state at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/DezhbanMenu/AppDelegate.swift | 24 +++++++-- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 53 ++++++++++++++++--- .../Sources/DezhbanMenu/SettingsView.swift | 16 ++++-- packaging/macos/uninstall.sh | 17 +++++- 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index f2dafe4..40793c7 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -191,12 +191,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // The backstop got there first and is opening the window. break case .blocked(let why): - // The request cannot be removed — a delete-denying ACL, `chflags - // uchg`. Permanent, so the *backstop* stands down (it would repeat - // every tick), but this handler is a one-shot event tied to a real - // user launch: standing down here would make that launch the silent - // no-op the mechanism exists to prevent, and every launch after it. + // A request that cannot be claimed proves nothing, so it is held to + // the same rule as no request at all. + // + // Since the claim is a rename, `.blocked` means the directory is + // unwritable — which is also why no *poster* could have written this + // file. It is a leftover, and `discard()` cannot clear it either, so + // acting on it unconditionally turned every later notification into + // an activation: fresh token each time, never matching, window + // reopened indefinitely with no in-product recovery. A real launch in + // that state cannot write its file either, so it arrives with the + // fileless marker and is accepted on that basis instead. NSLog("DezhbanMenu: hand-off request could not be claimed: \(why)") + guard acceptWithoutFile else { return } DispatchQueue.main.async { self?.openForHandoff(token: postedToken) } } } @@ -279,7 +286,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { case .fresh(let token): claimed = token case .blocked(let why): + // Stand down for real: the comment used to say the backstop stops on + // this while only returning from the one tick, so a 0.5s timer over a + // 5s window logged the same permanent condition eleven times. NSLog("DezhbanMenu: hand-off request could not be claimed: \(why)") + DispatchQueue.main.async { + self?.handoffTimer?.invalidate() + self?.handoffTimer = nil + } return case .absent, .lost: return diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 5e50109..2066112 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -131,6 +131,22 @@ enum LoginItem { /// changing, not on a refresh. var awaitsApproval: Bool { self == .awaitingApproval } + /// Whether this outcome is a statement about the switch's own state, so that + /// the switch later moving away from it makes the message false. + /// + /// `.unstableLocation` is not: it says this copy of the app may not touch the + /// login item, which stays true however the switch reads — and on a dev build + /// beside an installed copy the switch reads ON permanently, so treating it + /// as switch-describing wiped the explanation on the very next activation. + var describesSwitchState: Bool { + switch self { + case .unstableLocation, .failed: return false + case .enabled, .disabled, .awaitingApproval, .legacyStuck, .agentStuck, + .blockedByLegacy: + return true + } + } + /// Whether anything starts the app at login — what the Settings switch /// shows. var isOn: Bool { @@ -412,17 +428,19 @@ enum LoginItem { // told a user who had just asked to turn login-at-launch ON to go and // remove the login item — the opposite of what they wanted, in the case // the comment above names as the main way here. - switch service.status { + switch settledStatus() { case .enabled: return .enabled case .requiresApproval: return .awaitingApproval default: return .failed(error.localizedDescription) } } - // Checked, not assumed: `register()` returns without throwing when macOS - // is going to make the user approve it, and the switch snapping back with - // no explanation is indistinguishable from a bug. - if service.status == .requiresApproval { return .awaitingApproval } - return agentEnabled ? .enabled : .failed(describe(service.status)) + // One settled read, then every branch from it. `register()` returns without + // throwing when macOS is going to make the user approve it, so the status is + // the only place that shows up — and the switch snapping back with no + // explanation is indistinguishable from a bug. + let status = settledStatus() + if status == .requiresApproval { return .awaitingApproval } + return status == .enabled ? .enabled : .failed(describe(status)) } private static func disable() -> Outcome { @@ -800,6 +818,29 @@ enum LoginItem { return registered(target) } + /// The agent's status once it has had a moment to catch up with a `register()`. + /// + /// The mirror of `stillRegistered`, for the other direction, and it was missing: + /// the register tail took three separate status reads with no settle, so a status + /// that had not yet caught up gave `.failed` — `isOn == false` — and the switch + /// snapped OFF saying "macOS did not keep the registration" while the agent *was* + /// registered and the app would start at login. That is the same switch-versus- + /// reality lie `stillRegistered` was introduced to kill, on the register side. + /// Three reads also let the status change *between* them, which produced the + /// self-contradicting "Could not change the login item: the login item is + /// enabled." + /// + /// Returns on the first read that shows a registration; only the failing path + /// waits, and never on the main thread. + private static func settledStatus() -> SMAppService.Status { + for _ in 0 ..< 3 { + let status = service.status + if isRegistered(status) { return status } + usleep(50_000) + } + return service.status + } + private static func unregister(_ target: SMAppService, what: String) { do { try target.unregister() diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 03e331e..072bc75 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -883,7 +883,15 @@ struct SettingsView: View { // having split it from the pane's shared status. loginMessage = outcome.message loginMessageIsTransient = outcome.isTransient - loginMessageForEnabled = outcome.isOn + // Only for outcomes that describe the switch's own state. A + // refusal that has nothing to do with it — this copy may not + // touch the login item at all — is not invalidated by the switch + // moving, and tying it to `isOn == false` had it wiped on the + // next activation: a dev build beside an installed copy reads the + // switch ON permanently, so the disagreement never resolves and + // the explanation went every time, leaving the unexplained + // snap-back the message exists to prevent. + loginMessageForEnabled = outcome.describesSwitchState ? outcome.isOn : nil loginMessageAwaitsApproval = outcome.awaitsApproval // And bumped, so any status read that was already in flight // cannot land afterwards and clear this. `loginPending` cannot @@ -1046,9 +1054,9 @@ struct SettingsView: View { // cannot show, so only this can clear it; or the switch has since // moved away from the state it was written about. let approvalSettled = loginMessageAwaitsApproval && !live.awaitingApproval - if loginMessageIsTransient - || approvalSettled - || (!loginMessageAwaitsApproval && loginMessageForEnabled != live.enabled) { + let switchMoved = !loginMessageAwaitsApproval + && (loginMessageForEnabled.map { $0 != live.enabled } ?? false) + if loginMessageIsTransient || approvalSettled || switchMoved { loginMessage = nil loginMessageForEnabled = nil loginMessageAwaitsApproval = false diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 30da2a7..151b6d1 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -109,7 +109,22 @@ fi # agent, an `rm -rf` that deleted nothing, and a bundle that kept launching at # every subsequent login while the script said everything was removed. if [ ! -d "$APP" ]; then - for root in /Applications "${CONSOLE_HOME:+$CONSOLE_HOME/Applications}"; do + # Every account's ~/Applications, not only the console user's. CONSOLE_HOME is + # resolved only when somebody is logged in at the console, so run over ssh or at + # the login window this searched /Applications alone — and an install in a + # ~/Applications was never found, `rm -rf "$APP"` deleted nothing, and the script + # still printed "files deleted" and, on the no-console-user branch, "Nothing will + # start Dezhban (the app is gone)". The same false clean report the unbounded + # search was added to prevent, reached from the other side. + set -- /Applications + if [ -n "$CONSOLE_HOME" ] && [ -d "$CONSOLE_HOME/Applications" ]; then + set -- "$@" "$CONSOLE_HOME/Applications" + fi + for home_apps in /Users/*/Applications; do + [ -d "$home_apps" ] || continue + set -- "$@" "$home_apps" + done + for root in "$@"; do [ -d "$root" ] || continue # Unbounded depth, to match LoginItem.isInStableInstallLocation, which accepts # anything *under* an Applications directory. A depth limit here meant an From 7ab0a4201c0f77d32106d34e759c06a85c0b561a Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 09:35:49 +0330 Subject: [PATCH 33/36] fix(gui): remember several answered hand-offs, and stop find truncating a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty-second review round, three findings. (The round before this one stalled without producing a review; a stalled agent is not a clean pass, so it was relaunched rather than counted.) The token dedupe kept one slot, which three interleaved signals defeat. Two duplicates launching inside the backstop window: the second post() atomically replaces the first's file, so a backstop tick can claim T2 while both notifications still arrive, and the sequence T2, T1, T2 answered T2 twice — the second activation half a second later that the token design exists to prevent, on the exact gesture the checklist asks testers to perform. It remembers the last sixteen now. uninstall.sh took the bundle path from `find … | head -1`, which truncates at the first newline — legal in a macOS directory name — and the result is handed to `rm -rf` as root and used to exec the retraction errand. Demonstrated here: a bundle under "MyApps" gave /Applications/My. find writes the path itself now, into a root-owned mktemp directory, and printf without a trailing newline means the command substitution reproduces it exactly. Verified that the new form resolves to the real directory where the old one did not. And ADR-0014 still described the superseded timing design — a three-second debounce, "once per debounce interval", "the debounce is a rate limit, not a gate" — three paragraphs before correctly describing what actually ships, and two rounds after the debounce was deleted. Editing it is right while it is unmerged; once shipped it would have to be superseded instead. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 48 ++++++++++--------- .../Sources/DezhbanMenu/AppDelegate.swift | 25 ++++++++-- packaging/macos/uninstall.sh | 19 +++++++- 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 23fad27..317ed94 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -129,8 +129,8 @@ with an argument, the pre-`SMAppService` pattern. Only the session owner answers hand-offs. A copy that could not take the lock and started anyway — the `.unavailable` path, which exists so a broken support directory cannot stop the app launching — runs beside the real owner, and if both - answered, one double-click would open two windows, each with its own debounce, so - neither could suppress the other. + answered, one double-click would open two windows: each tracks separately which + requests it has answered, so neither can recognise the other's. A launch the *user* performed must never become a silent no-op, so the copy that loses the lock focuses the winner and posts a distributed notification @@ -178,31 +178,28 @@ with an argument, the pre-`SMAppService` pattern. conclude they should act — and refusing to act on the ambiguous ones turns a hand-off into the silent no-op the mechanism exists to prevent, which is the worse failure of the two. So the notification acts on everything except `.lost`, - the backstop acts only on `.fresh`, and the ambiguous signals' *effect* is - debounced: an open within three seconds of a previous hand-off open is dropped. - Debouncing what the user notices is cheaper and safer than making two - asynchronous signals agree. - - Which of the two signals is acting is settled by identity, not by timing. Each - request carries a token, written into the file and repeated in the notification, so - whichever signal arrives second is recognised as describing a request already - answered while a genuinely new launch — new token — always opens. Three earlier - versions inferred this from elapsed time plus a definite/indefinite flag, and every - one of them both let a duplicate window through in one ordering and swallowed a real - second launch in another, because elapsed time does not carry that information. - - Only the ambiguous ones, though. A claim of `.fresh` means the caller took a - request nobody else had, so it is a distinct launch by definition — two - double-clicks a second apart with the window closed in between are two requests - and both must be answered. Debouncing on elapsed time alone swallowed the second, - which is the silent no-op again, arrived at from the other direction. + and the backstop acts only on `.fresh`. + + Which signal acts is settled by identity, never by timing. Each request carries a + token, written into the file and repeated in the notification, so a signal + describing a request already answered is recognised as such while a genuinely new + launch — new token — always opens. Several answered tokens are remembered, not one: + two duplicates launching inside the backstop window put three signals in flight, + since the second `post()` atomically replaces the first's file, and a single slot + answered one of them twice. + + Three earlier versions inferred this from elapsed time plus a definite/indefinite + flag, and every one of them both let a duplicate window through in one ordering and + swallowed a real second launch in another, because elapsed time does not carry the + information being asked for. There is no time-based debounce anywhere in the + shipped design. And accepting an ambiguous notification is bounded to the launch window, because `DistributedNotificationCenter` is a system-wide bus with no sender authentication and both the name and the object are derivable. Unbounded, any process running as this user could call `MainWindow.open()` — which activates the - app — once per debounce interval indefinitely, reopening a window the moment it - was closed. Requiring a file outside the launch window costs nothing real (the + app — indefinitely, reopening a window the moment it was closed. Requiring a file + outside the launch window costs nothing real (the file is written before the post, so only the microsecond gap between the two needs the exemption) and removes the channel — with one addition: a poster whose file could not be written says so in the notification, because it is then the only @@ -210,7 +207,12 @@ with an argument, the pre-`SMAppService` pattern. unconditionally turned a full or read-only home into the silent no-op the mechanism exists to prevent, and the exemption weakens nothing that was not already reachable, since anything able to forge the notification could equally run - `open -a Dezhban`. The debounce is a rate limit, not a gate. Both consumers do their claim off the main thread, since it is a stat and an + `open -a Dezhban`. A request that cannot be claimed at all is held to the same rule + as no request: since the claim is a rename, that state means the directory is + unwritable, which is also why no poster could have written the file — it is a + leftover, and acting on it turned every later notification into an activation. + + Both consumers do their claim off the main thread, since it is a stat and an unlink and a network or relocated home would otherwise block the run loop on the one path meant to feel instant. diff --git a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift index 40793c7..476ea88 100644 --- a/gui/macos/Sources/DezhbanMenu/AppDelegate.swift +++ b/gui/macos/Sources/DezhbanMenu/AppDelegate.swift @@ -22,9 +22,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var updateTimer: Timer? /// Runs only for a few seconds after launch — see `startHandoffBackstop`. private var handoffTimer: Timer? - /// The last hand-off request acted on, so the two signals for one request - /// cannot open the window twice — see `openForHandoff`. - private var lastHandoffToken: String? + /// Requests already answered, most recent last — see `openForHandoff`. + /// + /// A set rather than a single slot. Two duplicates launching inside the backstop + /// window put *three* signals in flight — D2's `post()` atomically replaces D1's + /// file, so a backstop tick can claim T2 while both notifications still arrive — + /// and with one slot the sequence T2, T1, T2 answered T2 twice: the second + /// activation half a second later that the token design was introduced to + /// prevent, on the exact gesture the checklist asks testers to perform. + private var answeredHandoffTokens: [String] = [] + /// Enough for any plausible burst of interleaved signals, small enough to stay a + /// linear scan of nothing. + private static let answeredHandoffTokenLimit = 16 private var snapshot: Snapshot? private var lastMtime: Date? private var lastIconKey: String? @@ -230,8 +239,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// whose file never landed), so it is always acted on: an extra window is the /// lesser failure. private func openForHandoff(token: String?) { - if let token, token == lastHandoffToken { return } - lastHandoffToken = token + if let token { + if answeredHandoffTokens.contains(token) { return } + answeredHandoffTokens.append(token) + if answeredHandoffTokens.count > Self.answeredHandoffTokenLimit { + answeredHandoffTokens.removeFirst( + answeredHandoffTokens.count - Self.answeredHandoffTokenLimit) + } + } MainWindow.shared.open() } diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 151b6d1..80f6bd2 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -109,6 +109,11 @@ fi # agent, an `rm -rf` that deleted nothing, and a bundle that kept launching at # every subsequent login while the script said everything was removed. if [ ! -d "$APP" ]; then + # Root-owned, mode 700, for the same reason the errand's marker directory is: + # this runs as root and a predictable name in a world-writable directory is a + # symlink-plant away from a root write. + search_dir=$(mktemp -d "${TMPDIR:-/tmp}/dezhban-search.XXXXXX") || search_dir="" + search_out="$search_dir/found" # Every account's ~/Applications, not only the console user's. CONSOLE_HOME is # resolved only when somebody is logged in at the console, so run over ssh or at # the login window this searched /Applications alone — and an install in a @@ -125,6 +130,7 @@ if [ ! -d "$APP" ]; then set -- "$@" "$home_apps" done for root in "$@"; do + [ -n "$search_dir" ] || break [ -d "$root" ] || continue # Unbounded depth, to match LoginItem.isInStableInstallLocation, which accepts # anything *under* an Applications directory. A depth limit here meant an @@ -132,13 +138,24 @@ if [ ! -d "$APP" ]; then # /Applications/Utilities/Network/Tools/Dezhban.app — was one the uninstaller # could not find, so it printed "Nothing will start Dezhban (the app is gone)" # over a bundle still sitting there launching at every login. - found=$(find "$root" -name Dezhban.app -type d -print 2>/dev/null | head -1) + # Written to a file by find itself, not piped through `head`. `$(… | head -1)` + # truncates at the first newline, and a directory name may legally contain one + # on macOS — so `/Applications/MyApps/Dezhban.app` yielded + # APP=/Applications/My, which is then handed to `rm -rf` as root and used to + # exec the retraction errand. `-quit` stops at the first match, and printf + # without a trailing newline means the command substitution below reproduces + # the path exactly, embedded newlines included. + : >"$search_out" + find "$root" -name Dezhban.app -type d \ + -exec sh -c 'printf "%s" "$1" >"$2"' _ {} "$search_out" \; -quit 2>/dev/null + found=$(cat "$search_out") if [ -n "$found" ]; then APP="$found" echo "note: found the app at $APP" >&2 break fi done + [ -n "$search_dir" ] && rm -rf "$search_dir" fi if [ -n "$CONSOLE_UID" ]; then echo "unregistering the login agent for $CONSOLE_USER ..." From 1649a6948e80cbbe5254d2ef82abff0cb15c1836 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 09:57:26 +0330 Subject: [PATCH 34/36] fix: uninstall every install found, and yield only to a live incumbent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty-third review round, three findings. enable() was the one writer in LoginItem that retracted before recording the user's intent, inverting the rule the rest of the file states twice. retractLegacy() unloads a launchd job and in a login-started session that job's process is this app, so a kill between the retraction and the write left the "user turned this off" flag still reading true with the attempt flag now set — and the next launch's migration fell straight through to markMigrated(), retiring the account with nothing registered, moments after the user asked for login-at-launch ON. The write is flushed first now, like its two siblings. The uninstaller's bundle search only ran when /Applications/Dezhban.app was absent. SessionLock is path-keyed and isInStableInstallLocation accepts any Applications directory precisely so two installs can coexist, so the copy holding the agent registration need not be the one at the default path: the errand then ran from a bundle that had never registered — where the status is truthfully "nothing to do" — rm -rf deleted the wrong copy, and the script closed with an unqualified "files deleted" while the survivor kept starting the app at every login. The search now runs unconditionally and records every match, and each bundle's errand runs from that bundle before it is removed, because only the registering bundle can retract its own registration. And .heldByAnother exited unconditionally, even when no live copy of this install owns the lock. flock is released by the kernel when its holder dies, so locally "held" implies "alive" — but over a network home the server emulates it, and an advisory lock can outlive its process. Every launch then posted a hand-off nobody would claim and exited: permanently unstartable, silently, which is the opposite of how every other failure here degrades. It retries the lookup, since at login the incumbent is often launchd-exec'd and not yet LaunchServices-registered — the ordinary reason to find nobody, and one that must still yield — and starts anyway only when nobody turns up. Co-Authored-By: Claude Opus 5 (1M context) --- docs/contribute/testing.md | 7 ++ gui/macos/Sources/DezhbanMenu/LoginItem.swift | 17 ++-- gui/macos/Sources/DezhbanMenu/main.swift | 40 +++++++-- packaging/macos/uninstall.sh | 83 +++++++++++++++---- 4 files changed, 115 insertions(+), 32 deletions(-) diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index f810e57..43d7412 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -931,6 +931,13 @@ task gui:build && open dist/Dezhban.app `com.behnam-rk.dezhban.app.login` (that is `AssociatedBundleIdentifiers` doing its job — this is the switch a user reaches for to stop the app starting at login, and it is useless if nobody can tell what it governs). +- [ ] **Uninstall handles two installs.** Put a copy in `/Applications` *and* one in + `~/Applications`, let the second register the login agent, then run the + uninstaller. Both bundles must be gone, the agent registration must be + retracted (`launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` fails, + still after a reboot), and no clean-removal message may print over a survivor. + Only the registering bundle can retract, so each copy's errand has to run from + that copy while it still exists. - [ ] **Uninstall finds the app where it actually is.** Move `Dezhban.app` into `/Applications/Utilities/`, let it register the login agent, then run the uninstaller. It must locate the bundle there, retract the agent, delete it, diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 2066112..0f82a93 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -387,6 +387,16 @@ enum LoginItem { // engineered around: if macOS will not retract the old registration, // nothing the app can do will make enabling this safe. See // `Outcome.legacyStuck`. + // The user's intent is recorded and flushed BEFORE the retraction, which is + // the rule the rest of this file follows and this path inverted. + // `retractLegacy()` unloads a launchd job, and in a login-started session + // that job's process is this app — so a kill between the retraction and the + // write left `userDisabledKey` still reading true with the attempt flag now + // set, and the next launch's migration fell straight through to + // `markMigrated()`: the account retired with nothing registered, moments + // after the user asked for login-at-launch on. + UserDefaults.standard.set(false, forKey: userDisabledKey) + UserDefaults.standard.synchronize() retractLegacy() if stillRegistered(.mainApp) { // Which outcome depends on what actually survived, not on the direction @@ -404,13 +414,6 @@ enum LoginItem { // reported "left off" over a live login launch. return liveOutcome(.enabling, fallback: .blockedByLegacy) } - UserDefaults.standard.set(false, forKey: userDisabledKey) - // Flushed, like every other write to these three coupled flags. This pane - // can have its process killed by launchd mid-operation, and clearing the - // explicit-off only in memory meant the next launch still read it as true — - // marking the account migrated and permanently cancelling the register() - // retry that exists so nobody is stranded with nothing starting the app. - UserDefaults.standard.synchronize() do { try service.register() } catch { diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index f4d7877..3af78e0 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -131,18 +131,42 @@ func acquireSessionOwnership() -> SessionLock? { // to claim, losing that user's double-click. return lock case .heldByAnother: - // A background launch loses silently — that copy was never going to show - // the user anything. A launch the user performed must not be a no-op, so - // hand them over to the instance that owns the session. - if !LaunchVisibility.isBackgroundLaunch(arguments: CommandLine.arguments) { - let mePID = ProcessInfo.processInfo.processIdentifier - let incumbent = NSRunningApplication + // Somebody holds the lock — but only a *live* somebody may be handed the + // launch. `flock` is released by the kernel when its holder dies, so locally + // "held" implies "alive"; over a network home, where the server emulates it, + // an advisory lock can outlive the process that took it. Every launch then + // finds the lock taken, posts a hand-off nobody will ever claim, and exits — + // the app becomes permanently unstartable with nothing said, which is worse + // than the duplicate icon this guard exists to avoid, and is the opposite of + // how every other failure here degrades ("refusing to start because a support + // directory is broken would be a worse bug"). + // + // Retried rather than decided on one read: at login the incumbent is often + // launchd-exec'd and not yet registered with LaunchServices, which is the + // ordinary reason to find nobody and exactly the case that must still yield. + let mePID = ProcessInfo.processInfo.processIdentifier + let ownBundle = Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL + var incumbent: NSRunningApplication? + for attempt in 0 ..< 3 { + incumbent = NSRunningApplication .runningApplications(withBundleIdentifier: id) .first { $0.processIdentifier != mePID && !$0.isTerminated - && $0.bundleURL?.resolvingSymlinksInPath().standardizedFileURL - == Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL + && $0.bundleURL?.resolvingSymlinksInPath().standardizedFileURL == ownBundle } + if incumbent != nil { break } + if attempt < 2 { usleep(200_000) } + } + guard incumbent != nil else { + NSLog("DezhbanMenu: the session lock is held but no live copy of this install " + + "owns it (a stale lock on a network home?); starting anyway") + sessionHandoff?.discard() + return lock + } + // A background launch loses silently — that copy was never going to show + // the user anything. A launch the user performed must not be a no-op, so + // hand them over to the instance that owns the session. + if !LaunchVisibility.isBackgroundLaunch(arguments: CommandLine.arguments) { incumbent?.activate() // Ask it to open its window — which it may not currently have, since // the incumbent may be a --background login launch. diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 80f6bd2..45f2c4b 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -108,12 +108,20 @@ fi # that install got "the app bundle was already gone", an unloaded-for-this-boot # agent, an `rm -rf` that deleted nothing, and a bundle that kept launching at # every subsequent login while the script said everything was removed. -if [ ! -d "$APP" ]; then +# Unconditionally, and recording *every* match rather than stopping at the first. +# Gating this on /Applications/Dezhban.app being absent left a second install +# elsewhere untouched: `SessionLock` is path-keyed and `isInStableInstallLocation` +# accepts any Applications directory precisely so two can coexist, so the copy +# holding the agent registration may not be the one at the default path. The errand +# then ran from a bundle that had never registered — where the status is `.notFound` +# and `retractAll()` truthfully reports nothing to do — `rm -rf` deleted the wrong +# copy, and the script closed with an unqualified "files deleted" while the surviving +# bundle went on starting the app at every login. +if true; then # Root-owned, mode 700, for the same reason the errand's marker directory is: # this runs as root and a predictable name in a world-writable directory is a # symlink-plant away from a root write. search_dir=$(mktemp -d "${TMPDIR:-/tmp}/dezhban-search.XXXXXX") || search_dir="" - search_out="$search_dir/found" # Every account's ~/Applications, not only the console user's. CONSOLE_HOME is # resolved only when somebody is logged in at the console, so run over ssh or at # the login window this searched /Applications alone — and an install in a @@ -122,9 +130,12 @@ if [ ! -d "$APP" ]; then # start Dezhban (the app is gone)". The same false clean report the unbounded # search was added to prevent, reached from the other side. set -- /Applications - if [ -n "$CONSOLE_HOME" ] && [ -d "$CONSOLE_HOME/Applications" ]; then - set -- "$@" "$CONSOLE_HOME/Applications" - fi + case "$CONSOLE_HOME" in + /Users/*) ;; # already covered by the glob below + ?*) + [ -d "$CONSOLE_HOME/Applications" ] && set -- "$@" "$CONSOLE_HOME/Applications" + ;; + esac for home_apps in /Users/*/Applications; do [ -d "$home_apps" ] || continue set -- "$@" "$home_apps" @@ -145,19 +156,36 @@ if [ ! -d "$APP" ]; then # exec the retraction errand. `-quit` stops at the first match, and printf # without a trailing newline means the command substitution below reproduces # the path exactly, embedded newlines included. - : >"$search_out" - find "$root" -name Dezhban.app -type d \ - -exec sh -c 'printf "%s" "$1" >"$2"' _ {} "$search_out" \; -quit 2>/dev/null - found=$(cat "$search_out") - if [ -n "$found" ]; then - APP="$found" - echo "note: found the app at $APP" >&2 - break - fi + # One file per match, named by mktemp so nothing has to be counted, and each + # path written with printf so `$(cat …)` reproduces it byte for byte. Piping + # through `head -1` truncated at the first newline — legal in a macOS + # directory name — and the result is handed to `rm -rf` as root. + find "$root" -name Dezhban.app -type d -exec sh -c ' + out=$(mktemp "$2/bundle.XXXXXX") || exit 0 + printf "%s" "$1" >"$out"' _ {} "$search_dir" \; 2>/dev/null done - [ -n "$search_dir" ] && rm -rf "$search_dir" fi -if [ -n "$CONSOLE_UID" ]; then +# The default path counts as a candidate even if the search could not run. +if [ -n "$search_dir" ]; then + for f in "$search_dir"/bundle.*; do + [ -f "$f" ] || continue + APP=$(cat "$f") + echo "note: found the app at $APP" >&2 + break + done +fi +# Every bundle found, each retracting its own registration: only the registering +# bundle can call SMAppService.unregister(), so a second copy's agent is retractable +# by nothing else. +retract_and_remove_bundle() { + APP="$1" + if [ -n "$CONSOLE_UID" ]; then + run_retraction_errand + fi + rm -rf "$APP" +} + +run_retraction_errand() { echo "unregistering the login agent for $CONSOLE_USER ..." if [ ! -x "$APP/Contents/MacOS/DezhbanMenu" ]; then # Only the app can call SMAppService.unregister(), so with the bundle already @@ -253,6 +281,25 @@ if [ -n "$CONSOLE_UID" ]; then rm -rf "$errand_dir" wait "$errand" >/dev/null 2>&1 || true fi +} + +echo "removing the app bundle(s) ..." +bundles_handled=0 +if [ -n "$search_dir" ]; then + for f in "$search_dir"/bundle.*; do + [ -f "$f" ] || continue + retract_and_remove_bundle "$(cat "$f")" + bundles_handled=$((bundles_handled + 1)) + done + rm -rf "$search_dir" +fi +if [ "$bundles_handled" -eq 0 ]; then + # Nothing found anywhere: still run the errand so the "the bundle is gone, and + # only the app could have retracted this" warning is reached. + retract_and_remove_bundle "$APP" +fi + +if [ -n "$CONSOLE_UID" ]; then launchctl bootout "gui/$CONSOLE_UID/$LOGIN_AGENT" >/dev/null 2>&1 || true # The app's own per-user directory: the session lock the GUI takes at startup # to keep a second copy of itself from running, and the hand-off file beside @@ -296,7 +343,9 @@ else fi fi -rm -rf "$APP" +# The bundles are removed by `retract_and_remove_bundle` above, each after its own +# registration has been retracted — the removal cannot be hoisted here, because only +# the registering bundle can retract, so it has to still exist when its errand runs. # The daemon's own directory: state.json, learned.json, the command file and the # control socket. All machine-derived and safe to discard — none of it is the user's. From 6e8a1b5d8938cca566accc8cb5b8156996db40b6 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 10:13:24 +0330 Subject: [PATCH 35/36] fix(gui): decide a stale lock by where it lives, not by a timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty-fourth review round, five findings, all in last round's fix. The bounded incumbent probe was the wrong shape. At login the incumbent is launchd-exec'd and often not yet registered with LaunchServices, so 600ms times out on a *live* one — and the fallback then started a second copy of the app, which is the failure the session lock exists to prevent, on the common path. It also discarded the hand-off it had just posted, which the .unavailable case thirty lines above refuses to do for exactly the reason stated there. Locality decides it instead. Locally flock is the kernel's, so held means alive and the launch yields whether or not anyone has appeared in LaunchServices; the request it posts is claimed by the incumbent's own backstop. Only on a network home, where the server emulates the lock and it can outlive its holder, is "nobody there" allowed to mean stale — and there the app now also sets itself as the session owner, because otherwise it started, became the incumbent, and then ignored every later launch's hand-off: the silent no-op again, permanently. claim() renames the request aside before reading it, so a process killed in between — which this branch documents as reachable, since retracting a login item can have launchd terminate the app — orphaned a .claiming-* file that discard() never touches. Swept by whoever takes the lock, alongside the discard, since anything there then belongs to a predecessor. .unstableLocation asserted isOn == false from the disable direction, so a dev build beside an installed copy painted the switch OFF while login-at-launch was still live through the installed agent. Only outcomes that describe the switch's own state may move it. And the uninstaller deleted bundles in any account's ~/Applications while cleaning up only the console user's, leaving other users' session lock and hand-off file behind under a closing "files deleted". Root can reach those, so it removes them for every home; the preferences and the login item cannot be reached, and the closing warning now names the migration flag rather than only the Login Items entry. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0014-login-item-launch-marker.md | 9 ++++ docs/contribute/testing.md | 4 ++ .../Sources/DezhbanCore/HandoffRequest.swift | 18 +++++++ .../Sources/DezhbanCore/SessionLock.swift | 19 +++++++ .../Sources/DezhbanMenu/SettingsView.swift | 8 ++- gui/macos/Sources/DezhbanMenu/main.swift | 52 ++++++++++++------- packaging/macos/uninstall.sh | 21 ++++++-- 7 files changed, 107 insertions(+), 24 deletions(-) diff --git a/docs/adr/0014-login-item-launch-marker.md b/docs/adr/0014-login-item-launch-marker.md index 317ed94..94941b4 100644 --- a/docs/adr/0014-login-item-launch-marker.md +++ b/docs/adr/0014-login-item-launch-marker.md @@ -126,6 +126,15 @@ with an argument, the pre-`SMAppService` pattern. purged lock file while the incumbent holds its descriptor means the next launch creates a fresh inode, locks *that*, and runs a second copy undetectably. + What "held by another" is allowed to mean depends on where the lock lives. Locally + `flock` is the kernel's, so held means alive and a launch that finds it taken yields + even before LaunchServices has registered the incumbent — which at login it usually + has not, that being the ordinary case rather than an error. Only on a network home, + where the server emulates the lock and it can outlive its holder, may "nobody is + there" mean the lock is stale and the app start anyway; a timer cannot make that + distinction, and a bounded probe that tried started a second copy on the common + path. + Only the session owner answers hand-offs. A copy that could not take the lock and started anyway — the `.unavailable` path, which exists so a broken support directory cannot stop the app launching — runs beside the real owner, and if both diff --git a/docs/contribute/testing.md b/docs/contribute/testing.md index 43d7412..68cc56b 100644 --- a/docs/contribute/testing.md +++ b/docs/contribute/testing.md @@ -931,6 +931,10 @@ task gui:build && open dist/Dezhban.app `com.behnam-rk.dezhban.app.login` (that is `AssociatedBundleIdentifiers` doing its job — this is the switch a user reaches for to stop the app starting at login, and it is useless if nobody can tell what it governs). +- [ ] **No `.claiming-*` files accumulate.** After exercising hand-offs, and after a + forced quit during one, `ls -a ~/Library/Application Support/com.behnam-rk.dezhban.app/` + must show no `.claiming-*` leftovers — a process killed between the rename and + the read leaves one, and only the next session owner's sweep removes it. - [ ] **Uninstall handles two installs.** Put a copy in `/Applications` *and* one in `~/Applications`, let the second register the login agent, then run the uninstaller. Both bundles must be gone, the agent registration must be diff --git a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift index 53c35c4..80cefbc 100644 --- a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift +++ b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift @@ -106,6 +106,24 @@ public struct HandoffRequest { try? FileManager.default.removeItem(at: url) } + /// Removes `.claiming-*` files abandoned by a process that died mid-claim. + /// + /// `claim()` renames the request aside and then reads it, so a kill in between — + /// which this app documents as a real possibility, since retracting a login item + /// can have launchd terminate it — leaves a file `discard()` will never touch, + /// because that only knows the `.handoff` path. Swept by whoever takes the + /// session lock, alongside the discard, since anything found then belongs to a + /// predecessor. + public func sweepAbandonedClaims() { + let dir = url.deletingLastPathComponent() + guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { + return + } + for name in names where name.hasPrefix(".claiming-") { + try? FileManager.default.removeItem(at: dir.appendingPathComponent(name)) + } + } + /// Tries to take the request, and reports whether this caller owns it. /// /// The removal is what makes it a claim: exactly one `removeItem` can succeed diff --git a/gui/macos/Sources/DezhbanCore/SessionLock.swift b/gui/macos/Sources/DezhbanCore/SessionLock.swift index f480170..76fe686 100644 --- a/gui/macos/Sources/DezhbanCore/SessionLock.swift +++ b/gui/macos/Sources/DezhbanCore/SessionLock.swift @@ -123,6 +123,25 @@ public final class SessionLock { return .acquired } + /// Whether the lock file sits on a local volume, where `flock` is the kernel's + /// and is therefore released when its holder dies. + /// + /// The distinction decides what "held by another" is allowed to mean. Locally it + /// means a live holder, full stop — so a launch that finds the lock taken must + /// yield even when LaunchServices has not yet registered the incumbent, which at + /// login is the ordinary case. On a network home the server emulates the lock and + /// it can outlive the process that took it, and there yielding forever would make + /// the app permanently unstartable. + /// + /// Unknown counts as local: trusting the lock risks a launch that hands off + /// instead of opening, while distrusting it risks two copies of a kill switch's + /// UI, and the first is the smaller failure. + public var isOnLocalVolume: Bool { + let values = try? url.deletingLastPathComponent() + .resourceValues(forKeys: [.volumeIsLocalKey]) + return values?.volumeIsLocal ?? true + } + /// Drops the lock. Only tests need this: a real process holds the lock until /// it exits, and the kernel releases it then — including on a crash, which is /// the whole reason for using a lock rather than a pid file. diff --git a/gui/macos/Sources/DezhbanMenu/SettingsView.swift b/gui/macos/Sources/DezhbanMenu/SettingsView.swift index 072bc75..d3c02fb 100644 --- a/gui/macos/Sources/DezhbanMenu/SettingsView.swift +++ b/gui/macos/Sources/DezhbanMenu/SettingsView.swift @@ -877,7 +877,13 @@ struct SettingsView: View { // A newer click supersedes this one's result. guard revision == loginRevision else { return } loginPending = false - loginEnabled = outcome.isOn + // Only an outcome that describes the switch's own state may move + // it. `.unstableLocation` refuses the click and changes nothing, + // so asserting its `isOn == false` painted the switch OFF while + // login-at-launch was still live through the installed copy — + // the switch reading the opposite of reality, which is the one + // thing `Outcome` exists to prevent. + if outcome.describesSwitchState { loginEnabled = outcome.isOn } // Written unconditionally: this is the login item's own line, // so there is nothing else to collide with. That is the point of // having split it from the pane's shared status. diff --git a/gui/macos/Sources/DezhbanMenu/main.swift b/gui/macos/Sources/DezhbanMenu/main.swift index 3af78e0..97f3a53 100644 --- a/gui/macos/Sources/DezhbanMenu/main.swift +++ b/gui/macos/Sources/DezhbanMenu/main.swift @@ -117,8 +117,10 @@ func acquireSessionOwnership() -> SessionLock? { switch lock.acquire() { case .acquired: sessionOwnsLock = true - // Anything already on disk was meant for a predecessor, not for us. + // Anything already on disk was meant for a predecessor, not for us — and so + // is any `.claiming-*` file a predecessor died in the middle of. sessionHandoff?.discard() + sessionHandoff?.sweepAbandonedClaims() return lock case .unavailable(let why): // Never refuse to start over this. A duplicate icon is a smaller failure @@ -141,26 +143,38 @@ func acquireSessionOwnership() -> SessionLock? { // how every other failure here degrades ("refusing to start because a support // directory is broken would be a worse bug"). // - // Retried rather than decided on one read: at login the incumbent is often - // launchd-exec'd and not yet registered with LaunchServices, which is the - // ordinary reason to find nobody and exactly the case that must still yield. + // Decided on where the lock lives, not on a timer. Waiting for the incumbent + // to appear in LaunchServices was the wrong shape: at login it is + // launchd-exec'd and often not registered yet, so a bounded probe times out + // on a *live* incumbent — and then started a second copy of the app, the + // failure this lock exists to prevent, on the common path. It also discarded + // the hand-off it had just posted, which the `.unavailable` case thirty lines + // up refuses to do for exactly the reason stated there. + // + // Locally, `flock` is the kernel's: held means alive, so this yields whether + // or not anyone has shown up in LaunchServices yet, and the request it posts + // is claimed by the incumbent's own backstop. Only on a network home, where + // the lock can outlive its holder, may "nobody there" mean the lock is stale. let mePID = ProcessInfo.processInfo.processIdentifier let ownBundle = Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL - var incumbent: NSRunningApplication? - for attempt in 0 ..< 3 { - incumbent = NSRunningApplication - .runningApplications(withBundleIdentifier: id) - .first { - $0.processIdentifier != mePID && !$0.isTerminated - && $0.bundleURL?.resolvingSymlinksInPath().standardizedFileURL == ownBundle - } - if incumbent != nil { break } - if attempt < 2 { usleep(200_000) } - } - guard incumbent != nil else { - NSLog("DezhbanMenu: the session lock is held but no live copy of this install " - + "owns it (a stale lock on a network home?); starting anyway") - sessionHandoff?.discard() + let incumbent = NSRunningApplication + .runningApplications(withBundleIdentifier: id) + .first { + $0.processIdentifier != mePID && !$0.isTerminated + && $0.bundleURL?.resolvingSymlinksInPath().standardizedFileURL == ownBundle + } + if incumbent == nil, !lock.isOnLocalVolume { + NSLog("DezhbanMenu: the session lock is held on a network home but no live copy " + + "of this install owns it; treating it as stale and starting anyway") + // Not discarded: a request on disk may belong to a launch the real owner + // is about to answer, and this process took nothing. + // + // Owner enough to answer hand-offs, though. Without this the app started, + // became the incumbent, and then every later launch found it, posted a + // request and exited while nobody was listening — the silent no-op again, + // permanently, for that install. + sessionOwnsLock = true + sessionHandoff?.sweepAbandonedClaims() return lock } // A background launch loses silently — that copy was never going to show diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index 45f2c4b..e2f36a0 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -349,6 +349,16 @@ fi # The daemon's own directory: state.json, learned.json, the command file and the # control socket. All machine-derived and safe to discard — none of it is the user's. +# Every account's support directory, not only the console user's. The search above +# deliberately finds and deletes bundles in any account's ~/Applications, so scoping +# the cleanup to the console user left those users' session lock and hand-off file +# behind — under a closing message that says everything was removed. Root can reach +# these; the preferences and the login item cannot be, and are named in the warning. +for home in /Users/*; do + [ -d "$home/Library/Application Support/$APP_BUNDLE_ID" ] || continue + rm -rf "$home/Library/Application Support/$APP_BUNDLE_ID" +done + echo "removing daemon state at $STATE_DIR ..." rm -rf "$STATE_DIR" @@ -413,7 +423,10 @@ if [ "$SUPPORT_DIR_KEPT" = "1" ]; then echo " were removed.)" fi echo -echo "If any OTHER account on this Mac ran the app, its login agent is still" -echo "registered there — root cannot reach another user's launchd session. Nothing" -echo "will start Dezhban (the bundle is gone), but the entry lingers under System" -echo "Settings → General → Login Items until that user removes it there." +echo "If any OTHER account on this Mac ran the app, two things remain there that" +echo "root cannot reach: its login agent registration — nothing will start Dezhban," +echo "since the bundle is gone, but the entry lingers under System Settings >" +echo "General > Login Items until that user removes it — and Dezhban's saved" +echo "preferences, which record that the login-item migration has run, so a later" +echo "reinstall for that account would skip it. From that account:" +echo " defaults delete $APP_BUNDLE_ID" From ffb2805de4a4d606fe4342ced4c53b87e039d8b5 Mon Sep 17 00:00:00 2001 From: Behnam RK Date: Sat, 22 Aug 2026 10:33:25 +0330 Subject: [PATCH 36/36] fix(gui): a failed enable must move the switch back, and prune the app walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty-fifth review round, five findings. .failed was excluded from describesSwitchState alongside .unstableLocation, which was wrong in the dangerous direction. The binding moves the switch optimistically to where the user put it and only corrects it from an outcome that describes the switch — so a failed *enable* left it reading ON with nothing registered until the next activation. A failure is very much a statement about the switch; only .unstableLocation genuinely is not. sweepAbandonedClaims() deleted every .claiming-* in the support directory, which every install shares on purpose — SessionLock is path-keyed precisely so a dev build may run beside the installed copy. So one install's launch could delete the other's in-flight claim: the victim's read then returned nothing, its claim reported a nil token, nothing was recorded, and the paired notification opened the window a second time. The duplicate activation the token design exists to eliminate, caused by cleanup meant to be harmless. Claim files are named per request now, and the sweep is scoped to that prefix. The bundle search walked *inside* every installed application — millions of inodes on a Mac with Xcode, repeated per home directory, any of which may be a network mount that blocks — silently, after panic had torn the rules down and before anything was deleted. It prunes at every .app boundary now. The first attempt at that was wrong and the fixture caught it: `expr -a \( … \) -prune` evaluates the prune only when the whole conjunction is true, so a non-matching bundle fell through and was descended into anyway, which showed up as finding a Dezhban.app nested inside an Xcode.app fixture. Two pruned branches instead. Also: three doc comments had been merged onto `enum Direction`, leaving liveOutcome and describe undocumented while Direction's docs described neither of them; and the comment above the find still claimed `-quit` stops at the first match, two rounds after it was changed to record every match. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/DezhbanCore/HandoffRequest.swift | 18 ++++++- gui/macos/Sources/DezhbanMenu/LoginItem.swift | 50 +++++++++++-------- .../HandoffRequestTests.swift | 22 ++++++++ packaging/macos/uninstall.sh | 40 ++++++++------- 4 files changed, 90 insertions(+), 40 deletions(-) diff --git a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift index 80cefbc..2116a86 100644 --- a/gui/macos/Sources/DezhbanCore/HandoffRequest.swift +++ b/gui/macos/Sources/DezhbanCore/HandoffRequest.swift @@ -116,14 +116,28 @@ public struct HandoffRequest { /// predecessor. public func sweepAbandonedClaims() { let dir = url.deletingLastPathComponent() + let prefix = Self.claimPrefix(for: url) guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return } - for name in names where name.hasPrefix(".claiming-") { + for name in names where name.hasPrefix(prefix) { try? FileManager.default.removeItem(at: dir.appendingPathComponent(name)) } } + /// Claim files are named per *request*, not just "a claim in this directory". + /// + /// The directory is shared by every install of the app — `SessionLock` is + /// path-keyed precisely so `dist/Dezhban.app` may run beside the installed copy — + /// so a directory-wide sweep let one install delete the other's in-flight claim. + /// The victim's read then returned nothing, its claim reported a nil token, the + /// token was never recorded, and the paired notification opened the window a + /// second time: the duplicate activation the token design exists to eliminate, + /// caused by the cleanup meant to be harmless. + static func claimPrefix(for url: URL) -> String { + ".claiming-" + url.deletingPathExtension().lastPathComponent + "-" + } + /// Tries to take the request, and reports whether this caller owns it. /// /// The removal is what makes it a claim: exactly one `removeItem` can succeed @@ -151,7 +165,7 @@ public struct HandoffRequest { // "cannot dedupe this one", which the caller treats as its own identity // rather than as a match. let claimed = url.deletingLastPathComponent() - .appendingPathComponent(".claiming-\(UUID().uuidString)") + .appendingPathComponent("\(Self.claimPrefix(for: url))\(UUID().uuidString)") do { try FileManager.default.moveItem(at: url, to: claimed) } catch let error as NSError { diff --git a/gui/macos/Sources/DezhbanMenu/LoginItem.swift b/gui/macos/Sources/DezhbanMenu/LoginItem.swift index 0f82a93..5407d18 100644 --- a/gui/macos/Sources/DezhbanMenu/LoginItem.swift +++ b/gui/macos/Sources/DezhbanMenu/LoginItem.swift @@ -134,15 +134,23 @@ enum LoginItem { /// Whether this outcome is a statement about the switch's own state, so that /// the switch later moving away from it makes the message false. /// - /// `.unstableLocation` is not: it says this copy of the app may not touch the - /// login item, which stays true however the switch reads — and on a dev build - /// beside an installed copy the switch reads ON permanently, so treating it - /// as switch-describing wiped the explanation on the very next activation. + /// `.unstableLocation` is the only one that does not: it says this copy of the + /// app may not touch the login item, which stays true however the switch + /// reads — and on a dev build beside an installed copy the switch reads ON + /// permanently, so treating it as switch-describing wiped the explanation on + /// the very next activation. + /// + /// `.failed` used to be excluded alongside it, which was wrong in the + /// dangerous direction: the binding moves the switch optimistically to where + /// the user put it and only corrects it from an outcome that describes the + /// switch, so a failed *enable* left it reading ON with nothing registered + /// until the next activation — the switch-versus-reality lie this type exists + /// to prevent. A failure is very much a statement about the switch. var describesSwitchState: Bool { switch self { - case .unstableLocation, .failed: return false + case .unstableLocation: return false case .enabled, .disabled, .awaitingApproval, .legacyStuck, .agentStuck, - .blockedByLegacy: + .blockedByLegacy, .failed: return true } } @@ -737,21 +745,6 @@ enum LoginItem { return roots.contains { bundle == $0 || bundle.hasPrefix($0 + "/") } } - /// Words, not a raw `SMAppService.Status`. It is an imported `NS_ENUM` with no - /// `CustomStringConvertible`, so interpolating it put - /// `SMAppService.Status(rawValue: 3)` in front of the user — in the very type - /// that exists so the UI can say something true. - /// The truthful outcome for whatever is live right now. - /// - /// Both "the legacy item survived" branches used to answer from their own - /// branch — `legacyEnabled ? .legacyStuck : .blockedByLegacy` — which ignored - /// the agent. With a dormant `.requiresApproval` legacy item that will not - /// retract AND a live agent registration, that returned `.blockedByLegacy`: - /// `isOn == false`, a message asserting "it has been left off", and a switch - /// snapping OFF while `isEnabled` said true and the next `seed()` flipped it - /// back. Deriving from the live state instead makes `isOn` agree with - /// `isEnabled` by construction, which is what `isEnabled`'s docstring demands. - /// /// Which way the user was moving the switch. /// /// The live state alone is not enough: a registered agent means "on, as asked" @@ -765,6 +758,17 @@ enum LoginItem { /// explanation. enum Direction { case enabling, disabling } + /// The truthful outcome for whatever is live right now. + /// + /// Both "the legacy item survived" branches used to answer from their own + /// branch — `legacyEnabled ? .legacyStuck : .blockedByLegacy` — which ignored + /// the agent. With a dormant `.requiresApproval` legacy item that will not + /// retract AND a live agent registration, that returned `.blockedByLegacy`: + /// `isOn == false`, a message asserting "it has been left off", and a switch + /// snapping OFF while `isEnabled` said true and the next `seed()` flipped it + /// back. Deriving from the live state instead makes `isOn` agree with + /// `isEnabled` by construction, which is what `isEnabled`'s docstring demands. + /// /// `fallback` is used only when nothing is live at all. private static func liveOutcome(_ direction: Direction, fallback: Outcome) -> Outcome { if legacyEnabled { return .legacyStuck } @@ -787,6 +791,10 @@ enum LoginItem { return fallback } + /// Words, not a raw `SMAppService.Status`. It is an imported `NS_ENUM` with no + /// `CustomStringConvertible`, so interpolating it put + /// `SMAppService.Status(rawValue: 3)` in front of the user — in the very type + /// that exists so the UI can say something true. private static func describe(_ status: SMAppService.Status) -> String { switch status { case .notRegistered: return "macOS did not keep the registration." diff --git a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift index 25b21b5..4df36a1 100644 --- a/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift +++ b/gui/macos/Tests/DezhbanCoreTests/HandoffRequestTests.swift @@ -103,6 +103,28 @@ struct HandoffRequestTests { } } + /// One install's sweep must not touch another's in-flight claim. The support + /// directory is shared by every copy of the app on purpose, so a directory-wide + /// sweep silently broke the other copy's dedupe. + @Test func sweepingLeavesAnotherRequestsClaimsAlone() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let mine = HandoffRequest(url: dir.appendingPathComponent("instance-aaa.handoff")) + let theirs = HandoffRequest(url: dir.appendingPathComponent("instance-bbb.handoff")) + + // Stand in for a claim each install has renamed aside but not yet read. + let myClaim = dir.appendingPathComponent( + HandoffRequest.claimPrefix(for: mine.url) + "1") + let theirClaim = dir.appendingPathComponent( + HandoffRequest.claimPrefix(for: theirs.url) + "1") + try Data().write(to: myClaim) + try Data().write(to: theirClaim) + + mine.sweepAbandonedClaims() + #expect(!FileManager.default.fileExists(atPath: myClaim.path)) + #expect(FileManager.default.fileExists(atPath: theirClaim.path)) + } + /// Scoped per install, like the lock it sits beside — two installs may /// legitimately run side by side. @Test func theRequestSitsBesideItsOwnLock() { diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh index e2f36a0..c7ba454 100755 --- a/packaging/macos/uninstall.sh +++ b/packaging/macos/uninstall.sh @@ -143,26 +143,32 @@ if true; then for root in "$@"; do [ -n "$search_dir" ] || break [ -d "$root" ] || continue - # Unbounded depth, to match LoginItem.isInStableInstallLocation, which accepts - # anything *under* an Applications directory. A depth limit here meant an - # install the app would happily register the login agent from — say - # /Applications/Utilities/Network/Tools/Dezhban.app — was one the uninstaller + # Any depth under an Applications directory, to match + # LoginItem.isInStableInstallLocation — a depth limit meant an install the app + # would happily register the login agent from, say + # /Applications/Utilities/Network/Tools/Dezhban.app, was one the uninstaller # could not find, so it printed "Nothing will start Dezhban (the app is gone)" - # over a bundle still sitting there launching at every login. - # Written to a file by find itself, not piped through `head`. `$(… | head -1)` - # truncates at the first newline, and a directory name may legally contain one - # on macOS — so `/Applications/MyApps/Dezhban.app` yielded - # APP=/Applications/My, which is then handed to `rm -rf` as root and used to - # exec the retraction errand. `-quit` stops at the first match, and printf - # without a trailing newline means the command substitution below reproduces - # the path exactly, embedded newlines included. + # over a bundle still launching at every login. + # + # But pruned at every .app boundary. Unpruned, this walked *inside* every + # installed application — millions of inodes on a Mac with Xcode, repeated for + # each home directory, any of which may be a network mount that blocks — while + # the script sat silent, after `panic` had already torn the rules down and + # before anything was deleted. Nothing useful lives inside another app bundle. + # # One file per match, named by mktemp so nothing has to be counted, and each # path written with printf so `$(cat …)` reproduces it byte for byte. Piping - # through `head -1` truncated at the first newline — legal in a macOS - # directory name — and the result is handed to `rm -rf` as root. - find "$root" -name Dezhban.app -type d -exec sh -c ' - out=$(mktemp "$2/bundle.XXXXXX") || exit 0 - printf "%s" "$1" >"$out"' _ {} "$search_dir" \; 2>/dev/null + # through `head -1` truncated at the first newline — legal in a macOS directory + # name — and the result is handed to `rm -rf` as root. + # Two pruned branches, not one chain: `expr -a \( … \) -prune` evaluates the + # prune only when the whole conjunction is true, so a non-matching bundle fell + # through and was descended into anyway — verified by finding a Dezhban.app + # nested inside an Xcode.app fixture. + find "$root" \ + \( -name Dezhban.app -type d -exec sh -c ' + out=$(mktemp "$2/bundle.XXXXXX") || exit 0 + printf "%s" "$1" >"$out"' _ {} "$search_dir" \; -prune \) \ + -o \( -name '*.app' -type d -prune \) 2>/dev/null done fi # The default path counts as a candidate even if the search could not run.