Add Open at Login

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) <noreply@anthropic.com>
This commit is contained in:
Kaede AkinoandClaude Opus 5 committed 2026-09-02 19:07:54 +08:00
1 parent b13c87cbe8
commit 9bd2401a5c
7 files changed
+458

No files matched your search

+5
View File
@@ -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.
+17
View File
@@ -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
+44
View File
@@ -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()
}
@@ -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() }
}
}
}
+104
View File
@@ -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
}
}
+70
View File
@@ -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" : {
+168
View File
@@ -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)
}
}