From 9bd2401a5cd44ec238fcc8cb7b72d0a0482c871d Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 2 Sep 2026 19:07:54 +0800 Subject: [PATCH] Add Open at Login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › App › Startup gains an Open at Login switch backed by SMAppService.mainApp, and an Open Hidden at Login switch that keeps the login launch from taking the screen. Both are off by default. The registration is not mirrored into preferences. SMAppService is the store for whether Termind opens at login: the user can revoke it in System Settings without telling the app, so the switch re-reads the system status on appear and on every window activation. requiresApproval counts as on, with a row saying what is outstanding and a button into Login Items, rather than a switch that quietly refuses to hold. Hidden means NSApp.hide(nil) rather than a suppressed window, because Termind terminates with its last window. LoginLaunch latches the launch Apple Event during applicationWillFinishLaunching, the only moment currentAppleEvent can still be read; the hide is armed a second time for the window SwiftUI orders front just afterwards. Co-Authored-By: Claude Opus 5 (1M context) --- Memory/ARCHITECTURE.md | 5 + Termind/App/SettingsKey.swift | 17 ++ Termind/App/TermindApp.swift | 44 +++++ .../Features/Settings/SettingsPageView.swift | 50 ++++++ Termind/Platform/macOS/LoginItem.swift | 104 +++++++++++ Termind/Resources/Localizable.xcstrings | 70 ++++++++ TermindTests/LoginItemTests.swift | 168 ++++++++++++++++++ 7 files changed, 458 insertions(+) create mode 100644 Termind/Platform/macOS/LoginItem.swift create mode 100644 TermindTests/LoginItemTests.swift diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 97c927c0..ccca1f7b 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -50,6 +50,11 @@ including that no type AgentKit owns is redefined here. - Settings uses app-wide preference storage but window-scoped navigation. It opens as a top-level window destination, remembers the content it replaced, and restores that destination when its index page goes back. +- Opening at login is `SMAppService`'s registration, not a preference. The user can revoke + it in System Settings without telling the app, so `LoginItemController` reads the system + status every time Settings is shown rather than mirroring it into `UserDefaults`. Only + *how* that launch behaves is stored here. A quiet login launch hides the app rather than + suppressing its window, because Termind terminates with its last window. Repository data is persistent truth. Runtime connection state is kept in a connection registry and is never persisted onto domain records. diff --git a/Termind/App/SettingsKey.swift b/Termind/App/SettingsKey.swift index ae556b4f..6f337be5 100644 --- a/Termind/App/SettingsKey.swift +++ b/Termind/App/SettingsKey.swift @@ -60,6 +60,23 @@ nonisolated enum SettingsKey { /// Restore the previous window's Workspaces on launch. Keep the persisted /// key stable so this UI rename does not discard the user's preference. static let restoreWorkspaces = "window.restoreTabs" + /// Keep Termind behind whatever the user is doing when macOS opens it at + /// login. Only the launch is quiet; the window is built as usual. + /// + /// Off by default: an app that opens where it cannot be seen is a surprise + /// worth asking for rather than one to hand out. + /// + /// Whether Termind opens at login at all is not here. That registration + /// belongs to `SMAppService`, which the user can undo in System Settings + /// without telling us — see `LoginItemController`. + static let openAtLoginQuietly = "startup.openAtLogin.quietly" + static let defaultOpenAtLoginQuietly = false + /// Read from the app delegate during launch, where there is no view to hold + /// an `@AppStorage` binding. + static var opensAtLoginQuietly: Bool { + UserDefaults.standard.object(forKey: openAtLoginQuietly) as? Bool + ?? defaultOpenAtLoginQuietly + } /// The last approval posture selected in the Assistant composer. static let agentPermissionMode = "assistant.permissionMode" /// How far through `AgentSkillLibrary` the starter skills have been copied diff --git a/Termind/App/TermindApp.swift b/Termind/App/TermindApp.swift index 043515e8..6e93771d 100644 --- a/Termind/App/TermindApp.swift +++ b/Termind/App/TermindApp.swift @@ -150,8 +150,18 @@ struct TermindApp: App { /// for that item, so we retarget it here to ⇧⌘W and leave ⌘W to `TermindCommands`. final class AppDelegate: NSObject, NSApplicationDelegate { weak var app: AppModel? + private var quietLaunchObserver: (any NSObjectProtocol)? + + /// The launch Apple Event is readable only while it is being handled. + func applicationWillFinishLaunching(_ notification: Notification) { + LoginLaunch.capture() + } func applicationDidFinishLaunching(_ notification: Notification) { + // Asked for again in case SwiftUI installed this delegate too late + // for the call above; `capture()` only ever latches a `true`. + LoginLaunch.capture() + openHiddenIfLaunchedAtLogin() AppShortcutRegistry.shared.register(AppShortcutRegistration("q")) if let items = NSApp.mainMenu?.items { for menuItem in items { @@ -191,6 +201,40 @@ struct TermindApp: App { true } + /// Opening at login is for having Termind *available*, not for taking the + /// screen from whatever the user opened their Mac to do. Hidden rather + /// than window-less: Termind quits with its last window, so the window is + /// built as usual and simply never comes forward. + /// + /// The hide is armed twice because SwiftUI orders the `WindowGroup` + /// window front after this runs, which unhides the app again. Whichever + /// happens first wins, and the observer is torn down either way so a + /// window the user opens later is never hidden out from under them. + private func openHiddenIfLaunchedAtLogin() { + guard LoginLaunch.wasLaunchedAtLogin, SettingsKey.opensAtLoginQuietly else { return } + NSApp.hide(nil) + quietLaunchObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + NSApp.hide(nil) + self?.endQuietLaunch() + } + } + // A window that never becomes key — because the first hide already + // took — would otherwise leave the observer armed for the session. + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(3)) + self?.endQuietLaunch() + } + } + + private func endQuietLaunch() { + guard let observer = quietLaunchObserver else { return } + NotificationCenter.default.removeObserver(observer) + quietLaunchObserver = nil + } + func applicationWillTerminate(_ notification: Notification) { app?.shutdownForTermination() } diff --git a/Termind/Features/Settings/SettingsPageView.swift b/Termind/Features/Settings/SettingsPageView.swift index 8ceade42..9a6c6aca 100644 --- a/Termind/Features/Settings/SettingsPageView.swift +++ b/Termind/Features/Settings/SettingsPageView.swift @@ -487,11 +487,61 @@ private struct ProSettings: View { } private struct StartupSettings: View { + @Environment(\.appearsActive) private var appearsActive + @State private var loginItem = LoginItemController() + @AppStorage(SettingsKey.openAtLoginQuietly) private var opensQuietly = + SettingsKey.defaultOpenAtLoginQuietly @AppStorage(SettingsKey.restoreWorkspaces) private var restoreWorkspaces = true + var body: some View { Section("Startup") { + Toggle( + isOn: Binding( + get: { loginItem.isEnabled }, + set: { loginItem.setEnabled($0) } + ) + ) { + VStack(alignment: .leading, spacing: Metrics.Space.xxs) { + Text("Open at Login") + Text( + "macOS opens Termind when you log in, which also starts the local MCP server if you have it enabled." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Toggle(isOn: $opensQuietly) { + VStack(alignment: .leading, spacing: Metrics.Space.xxs) { + Text("Open Hidden at Login") + Text("Termind opens behind your other windows. Click its icon in the Dock to show it.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .disabled(!loginItem.isEnabled) + + if loginItem.requiresApproval { + LabeledContent { + Button("Open Login Items Settings") { loginItem.openSystemSettings() } + } label: { + Text("macOS is waiting for you to allow Termind in Login Items.") + .foregroundStyle(.secondary) + } + } + + if let message = loginItem.errorMessage { + Text(message).font(.caption).foregroundStyle(.orange) + } + Toggle("Restore Workspaces from Last Session", isOn: $restoreWorkspaces) } + // The registration lives in System Settings, where it can be revoked + // without telling us. Re-read it whenever this window is looked at. + .onAppear { loginItem.refresh() } + .onChange(of: appearsActive) { _, isActive in + if isActive { loginItem.refresh() } + } } } diff --git a/Termind/Platform/macOS/LoginItem.swift b/Termind/Platform/macOS/LoginItem.swift new file mode 100644 index 00000000..61b1c41a --- /dev/null +++ b/Termind/Platform/macOS/LoginItem.swift @@ -0,0 +1,104 @@ +import AppKit +import ServiceManagement + +/// Whether launchd opened this process as a login item. +/// +/// `SMAppService` reports whether the app is registered, never why the current +/// process started. The launch Apple Event still carries that, but +/// `currentAppleEvent` is only valid while the event is being handled and is +/// gone a moment later, so the answer is latched during +/// `applicationWillFinishLaunching` and read from there on. +@MainActor +enum LoginLaunch { + private(set) static var wasLaunchedAtLogin = false + + /// Latches a `true` and never clears it, so it can be called from both + /// `applicationWillFinishLaunching` and `applicationDidFinishLaunching` + /// without the second, later call erasing what the first one saw. + static func capture( + currentEvent: NSAppleEventDescriptor? = NSAppleEventManager.shared().currentAppleEvent + ) { + wasLaunchedAtLogin = wasLaunchedAtLogin || isLoginItemLaunch(currentEvent) + } + + /// Kept pure and separate from the latch so a test can build the descriptor + /// instead of logging out of macOS. + nonisolated static func isLoginItemLaunch(_ event: NSAppleEventDescriptor?) -> Bool { + guard let event else { return false } + return event.eventID == kAEOpenApplication + && event.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue + == keyAELaunchedAsLogInItem + } +} + +/// The login item, as macOS currently has it. +/// +/// Deliberately without a preference mirroring the registration. Someone can +/// remove Termind in System Settings › General › Login Items at any time, and a +/// remembered `true` would then describe a registration that no longer exists. +/// `SMAppService` is the store for *whether* Termind opens at login; only *how* +/// it opens is kept in preferences. +@MainActor +@Observable +final class LoginItemController { + private let readStatus: () -> SMAppService.Status + private let register: () throws -> Void + private let unregister: () throws -> Void + private let showSystemSettings: () -> Void + + private(set) var status: SMAppService.Status + private(set) var errorMessage: String? + + init( + status: @escaping () -> SMAppService.Status = { SMAppService.mainApp.status }, + register: @escaping () throws -> Void = { try SMAppService.mainApp.register() }, + unregister: @escaping () throws -> Void = { try SMAppService.mainApp.unregister() }, + showSystemSettings: @escaping () -> Void = { SMAppService.openSystemSettingsLoginItems() } + ) { + readStatus = status + self.register = register + self.unregister = unregister + self.showSystemSettings = showSystemSettings + self.status = status() + } + + /// On as far as this app is concerned. `requiresApproval` counts: the + /// request is on file and only the user's consent is outstanding, so the + /// switch keeps showing what they asked for instead of snapping back while + /// the row below explains what is missing. + var isEnabled: Bool { status == .enabled || status == .requiresApproval } + + /// macOS has the registration but the user has not allowed it yet. Nothing + /// opens at login until they do, which is why this is said out loud. + var requiresApproval: Bool { status == .requiresApproval } + + func refresh() { + status = readStatus() + errorMessage = nil + } + + /// Reports what macOS ended up doing rather than what was asked of it. + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + errorMessage = nil + do { + if enabled { try register() } else { try unregister() } + } catch { + errorMessage = Self.message(for: error) + } + status = readStatus() + // Waiting on approval is refused with "Operation not permitted", which + // says nothing the approval row does not say better. + if requiresApproval { errorMessage = nil } + } + + func openSystemSettings() { showSystemSettings() } + + private static func message(for error: any Error) -> String { + let description = error.localizedDescription + .trimmingCharacters(in: .whitespacesAndNewlines) + return description.isEmpty + ? String(localized: "Unable to update the login item.") + : description + } +} diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 9caf2304..7015f73a 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -6009,6 +6009,26 @@ } } }, + "macOS is waiting for you to allow Termind in Login Items." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS正在等待你在“登录项”中允许Termind。" + } + } + } + }, + "macOS opens Termind when you log in, which also starts the local MCP server if you have it enabled." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS会在你登录时打开Termind,如果你启用了本地MCP服务器,它也会随之启动。" + } + } + } + }, "Manage Models" : { "localizations" : { "zh-Hans" : { @@ -7201,6 +7221,16 @@ } } }, + "Open at Login" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录时打开" + } + } + } + }, "Open current directory with SFTP — right-click for more options" : { "comment" : "A contextual help for the open button in the status bar.", "isCommentAutoGenerated" : true, @@ -7233,6 +7263,16 @@ } } }, + "Open Hidden at Login" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录时隐藏打开" + } + } + } + }, "Open It" : { "comment" : "A button that opens a path.", "isCommentAutoGenerated" : true, @@ -7245,6 +7285,16 @@ } } }, + "Open Login Items Settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开“登录项”设置" + } + } + } + }, "Open serial port" : { "localizations" : { "zh-Hans" : { @@ -10762,6 +10812,16 @@ } } }, + "Termind opens behind your other windows. Click its icon in the Dock to show it." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind会在其他窗口后面打开。点按程序坞中的图标即可显示。" + } + } + } + }, "Termind Pro" : { "comment" : "The name of the app.", "isCommentAutoGenerated" : true, @@ -12216,6 +12276,16 @@ } } }, + "Unable to update the login item." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法更新登录项。" + } + } + } + }, "Unarchive" : { "localizations" : { "zh-Hans" : { diff --git a/TermindTests/LoginItemTests.swift b/TermindTests/LoginItemTests.swift new file mode 100644 index 00000000..d44ad390 --- /dev/null +++ b/TermindTests/LoginItemTests.swift @@ -0,0 +1,168 @@ +import AppKit +import Foundation +import ServiceManagement +import Testing + +@testable import Termind + +@Suite +@MainActor +struct LoginItemTests { + /// Stands in for `SMAppService.mainApp`, which a test cannot register + /// against without changing the machine it runs on. + @MainActor + private final class LoginItemSystem { + var status: SMAppService.Status + /// What the status reads as after a register attempt. macOS can both + /// throw *and* move the status: a registration the user has not allowed + /// is refused and lands in `requiresApproval`. + var statusAfterRegister: SMAppService.Status = .enabled + var registerError: (any Error)? + private(set) var registerCount = 0 + private(set) var unregisterCount = 0 + private(set) var systemSettingsCount = 0 + + init(status: SMAppService.Status) { self.status = status } + + func makeController() -> LoginItemController { + LoginItemController( + status: { self.status }, + register: { + self.registerCount += 1 + self.status = self.statusAfterRegister + if let error = self.registerError { throw error } + }, + unregister: { + self.unregisterCount += 1 + self.status = .notRegistered + }, + showSystemSettings: { self.systemSettingsCount += 1 } + ) + } + } + + private struct RegistrationFailure: LocalizedError { + var errorDescription: String? { "Operation not permitted" } + } + + private static func launchEvent( + eventID: AEEventID = AEEventID(kAEOpenApplication), + loginItemFlag: Bool + ) -> NSAppleEventDescriptor { + let event = NSAppleEventDescriptor( + eventClass: AEEventClass(kCoreEventClass), eventID: eventID, + targetDescriptor: nil, returnID: AEReturnID(kAutoGenerateReturnID), + transactionID: AETransactionID(kAnyTransactionID) + ) + if loginItemFlag { + event.setParam( + NSAppleEventDescriptor(enumCode: keyAELaunchedAsLogInItem), + forKeyword: keyAEPropData + ) + } + return event + } + + @Test + func loginLaunchIsDetectedOnlyForTheLoginItemOpenEvent() { + #expect(LoginLaunch.isLoginItemLaunch(Self.launchEvent(loginItemFlag: true))) + #expect(!LoginLaunch.isLoginItemLaunch(Self.launchEvent(loginItemFlag: false))) + #expect( + !LoginLaunch.isLoginItemLaunch( + Self.launchEvent(eventID: AEEventID(kAEReopenApplication), loginItemFlag: true) + ) + ) + #expect(!LoginLaunch.isLoginItemLaunch(nil)) + } + + @Test + func enablingRegistersOnceAndReportsTheSystemStatus() { + let system = LoginItemSystem(status: .notRegistered) + let controller = system.makeController() + #expect(!controller.isEnabled) + + controller.setEnabled(true) + + #expect(system.registerCount == 1) + #expect(controller.isEnabled) + #expect(!controller.requiresApproval) + #expect(controller.errorMessage == nil) + + // Setting what is already set asks macOS for nothing. + controller.setEnabled(true) + #expect(system.registerCount == 1) + } + + @Test + func approvalPendingKeepsTheSwitchOnAndAsksForSystemSettings() { + let system = LoginItemSystem(status: .notRegistered) + system.statusAfterRegister = .requiresApproval + system.registerError = RegistrationFailure() + let controller = system.makeController() + + controller.setEnabled(true) + + #expect(system.registerCount == 1) + #expect(controller.requiresApproval) + // Snapping back to off would deny the choice the user just made; the + // approval row is what explains why nothing opens yet. + #expect(controller.isEnabled) + // "Operation not permitted" adds nothing to that row. + #expect(controller.errorMessage == nil) + + controller.openSystemSettings() + #expect(system.systemSettingsCount == 1) + } + + @Test + func registrationFailureSurfacesTheErrorAndKeepsTheSystemStatusAuthoritative() { + let system = LoginItemSystem(status: .notFound) + system.statusAfterRegister = .notFound + system.registerError = RegistrationFailure() + let controller = system.makeController() + + controller.setEnabled(true) + + #expect(controller.errorMessage == "Operation not permitted") + #expect(!controller.isEnabled) + #expect(!controller.requiresApproval) + } + + @Test + func disablingUnregisters() { + let system = LoginItemSystem(status: .enabled) + let controller = system.makeController() + #expect(controller.isEnabled) + + controller.setEnabled(false) + + #expect(system.unregisterCount == 1) + #expect(!controller.isEnabled) + } + + @Test + func refreshingPicksUpARemovalMadeOutsideTheApp() { + let system = LoginItemSystem(status: .enabled) + let controller = system.makeController() + #expect(controller.isEnabled) + + system.status = .notRegistered + #expect(controller.isEnabled) + + controller.refresh() + #expect(!controller.isEnabled) + } + + @Test + func openingHiddenIsOffUntilTheUserAsksForIt() { + let defaults = UserDefaults.standard + let original = defaults.object(forKey: SettingsKey.openAtLoginQuietly) + defer { defaults.set(original, forKey: SettingsKey.openAtLoginQuietly) } + + defaults.removeObject(forKey: SettingsKey.openAtLoginQuietly) + #expect(!SettingsKey.opensAtLoginQuietly) + + defaults.set(true, forKey: SettingsKey.openAtLoginQuietly) + #expect(SettingsKey.opensAtLoginQuietly) + } +}