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) + } +}