From ad9cc5c650b63d26c10da4e23005b14c5870714e Mon Sep 17 00:00:00 2001 From: cuiko Date: Fri, 5 Jun 2026 17:23:08 +0800 Subject: [PATCH 1/4] feat(settings): Add Launch at login toggle to General tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch-at-login was only reachable from the status-bar menu, which is hidden entirely when the user enables "Hide menu bar icon" — leaving no way to change the setting from the UI. Surface it as a checkbox in Settings → General → Application alongside the hide-icon option. Extract the LaunchAgent plist read/write out of AppDelegate into a shared LaunchAtLogin helper so the menu item and the new checkbox read and mutate the same source of truth (the plist's presence), keeping the two controls in agreement without a separate stored flag. --- Sources/TermIMS/AppDelegate.swift | 21 ++---------- Sources/TermIMS/LaunchAtLogin.swift | 33 +++++++++++++++++++ .../TermIMS/SettingsWindowController.swift | 14 ++++++-- 3 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 Sources/TermIMS/LaunchAtLogin.swift diff --git a/Sources/TermIMS/AppDelegate.swift b/Sources/TermIMS/AppDelegate.swift index e0712b7..107dc4c 100644 --- a/Sources/TermIMS/AppDelegate.swift +++ b/Sources/TermIMS/AppDelegate.swift @@ -12,10 +12,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var permissionPollTimer: Timer? private var wasTrusted = false - private var launchAgentPath: String { - NSHomeDirectory() + "/Library/LaunchAgents/top.cuiko.termims.plist" - } - func applicationDidFinishLaunching(_ n: Notification) { Log.debug("=== TermIMS started ===") installEditMenu() @@ -177,7 +173,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { menu.addItem(.separator()) loginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLogin), keyEquivalent: "") loginItem.target = self - loginItem.state = FileManager.default.fileExists(atPath: launchAgentPath) ? .on : .off + loginItem.state = LaunchAtLogin.isEnabled ? .on : .off menu.addItem(loginItem) menu.addItem(.separator()) @@ -199,19 +195,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { settingsWC?.showWindow(nil); NSApp.activate(ignoringOtherApps: true) } @objc private func toggleLogin() { - let fm = FileManager.default - if fm.fileExists(atPath: launchAgentPath) { - try? fm.removeItem(atPath: launchAgentPath) - } else { - try? fm.createDirectory(atPath: NSHomeDirectory() + "/Library/LaunchAgents", withIntermediateDirectories: true) - let plist: NSDictionary = [ - "Label": "top.cuiko.termims", - "ProgramArguments": [Bundle.main.executablePath ?? ""], - "RunAtLoad": true, - ] - plist.write(toFile: launchAgentPath, atomically: true) - } - loginItem.state = fm.fileExists(atPath: launchAgentPath) ? .on : .off + LaunchAtLogin.setEnabled(!LaunchAtLogin.isEnabled) + loginItem.state = LaunchAtLogin.isEnabled ? .on : .off } @objc private func quitApp() { monitor.stop(); NSApp.terminate(nil) } } diff --git a/Sources/TermIMS/LaunchAtLogin.swift b/Sources/TermIMS/LaunchAtLogin.swift new file mode 100644 index 0000000..1aaa6ad --- /dev/null +++ b/Sources/TermIMS/LaunchAtLogin.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Launch-at-login backed by a LaunchAgent plist in ~/Library/LaunchAgents. +/// The plist's presence is the source of truth for the enabled state, so the +/// status-menu toggle and the Settings checkbox always agree without any +/// stored flag of their own. +enum LaunchAtLogin { + private static let label = "top.cuiko.termims" + + private static var plistPath: String { + NSHomeDirectory() + "/Library/LaunchAgents/\(label).plist" + } + + static var isEnabled: Bool { + FileManager.default.fileExists(atPath: plistPath) + } + + static func setEnabled(_ enabled: Bool) { + let fm = FileManager.default + if enabled { + try? fm.createDirectory(atPath: NSHomeDirectory() + "/Library/LaunchAgents", + withIntermediateDirectories: true) + let plist: NSDictionary = [ + "Label": label, + "ProgramArguments": [Bundle.main.executablePath ?? ""], + "RunAtLoad": true, + ] + plist.write(toFile: plistPath, atomically: true) + } else { + try? fm.removeItem(atPath: plistPath) + } + } +} diff --git a/Sources/TermIMS/SettingsWindowController.swift b/Sources/TermIMS/SettingsWindowController.swift index 38f0ddb..e794383 100644 --- a/Sources/TermIMS/SettingsWindowController.swift +++ b/Sources/TermIMS/SettingsWindowController.swift @@ -161,6 +161,11 @@ class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTab let appLabel = label("Application") appLabel.font = .systemFont(ofSize: 13, weight: .semibold) + let loginCheck = NSButton(checkboxWithTitle: "Launch at login", + target: self, action: #selector(launchAtLoginToggled(_:))) + loginCheck.translatesAutoresizingMaskIntoConstraints = false + loginCheck.state = LaunchAtLogin.isEnabled ? .on : .off + let hideCheck = NSButton(checkboxWithTitle: "Hide menu bar icon (reopen app to show Settings)", target: self, action: #selector(hideIconToggled(_:))) hideCheck.translatesAutoresizingMaskIntoConstraints = false @@ -181,7 +186,7 @@ class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTab clearBtn.controlSize = .regular for sub in [defLabel, defPopup, sep1, indLabel, indCheck, posLabel, posPopup, - sep2, appLabel, hideCheck, + sep2, appLabel, loginCheck, hideCheck, sep3, debugLabel, debugCheck, clearBtn] { v.addSubview(sub) } NSLayoutConstraint.activate([ defLabel.topAnchor.constraint(equalTo: v.topAnchor, constant: 20), @@ -210,7 +215,9 @@ class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTab appLabel.topAnchor.constraint(equalTo: sep2.bottomAnchor, constant: 16), appLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), - hideCheck.topAnchor.constraint(equalTo: appLabel.bottomAnchor, constant: 10), + loginCheck.topAnchor.constraint(equalTo: appLabel.bottomAnchor, constant: 10), + loginCheck.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), + hideCheck.topAnchor.constraint(equalTo: loginCheck.bottomAnchor, constant: 8), hideCheck.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), sep3.topAnchor.constraint(equalTo: hideCheck.bottomAnchor, constant: 20), @@ -242,6 +249,9 @@ class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTab @objc private func indicatorPosChanged(_ sender: NSPopUpButton) { RuleStore.shared.indicatorPosition = IndicatorPosition.allCases[sender.indexOfSelectedItem] } + @objc private func launchAtLoginToggled(_ sender: NSButton) { + LaunchAtLogin.setEnabled(sender.state == .on) + } @objc private func hideIconToggled(_ sender: NSButton) { RuleStore.shared.hideMenuBarIcon = sender.state == .on } From f51db495def9002d07017984ba16ca6e025e15fd Mon Sep 17 00:00:00 2001 From: cuiko Date: Fri, 5 Jun 2026 17:24:48 +0800 Subject: [PATCH 2/4] fix(settings): Sync status menu when launch-at-login toggled in Settings Ticking the new Settings checkbox wrote the LaunchAgent plist but left the status menu's "Launch at Login" item showing its stale state until the next unrelated menu rebuild. Unlike the RuleStore-backed settings, launch-at-login has no setter that posts a change notification. Post .rulesDidChange after the toggle so AppDelegate rebuilds the menu and re-reads LaunchAtLogin.isEnabled, matching how the store-backed toggles already keep the menu in sync. The extra FocusMonitor.reload this triggers is a cheap no-op when the observed bundle set is unchanged. --- Sources/TermIMS/SettingsWindowController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/TermIMS/SettingsWindowController.swift b/Sources/TermIMS/SettingsWindowController.swift index e794383..82e9a72 100644 --- a/Sources/TermIMS/SettingsWindowController.swift +++ b/Sources/TermIMS/SettingsWindowController.swift @@ -251,6 +251,11 @@ class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTab } @objc private func launchAtLoginToggled(_ sender: NSButton) { LaunchAtLogin.setEnabled(sender.state == .on) + // Launch-at-login lives in a plist, not RuleStore, so toggling it + // doesn't otherwise notify anyone. Post the same signal the store + // setters use so the status menu rebuilds and its "Launch at Login" + // item reflects the new state. + NotificationCenter.default.post(name: .rulesDidChange, object: nil) } @objc private func hideIconToggled(_ sender: NSButton) { RuleStore.shared.hideMenuBarIcon = sender.state == .on From 8d0d7e160e2bdd29ca92071193a50607febb94a7 Mon Sep 17 00:00:00 2001 From: cuiko Date: Fri, 5 Jun 2026 17:30:27 +0800 Subject: [PATCH 3/4] feat(settings): Back launch-at-login with SMAppService, drop menu item The plist-based LaunchAgent put TermIMS in System Settings' "Allow in the Background" list as a raw exec entry attributed to an "unidentified developer", because the agent pointed at the inner Mach-O rather than the app bundle. Switch to SMAppService.mainApp (macOS 13+), which registers the bundle itself so it shows up under "Open at Login" with the real icon and name. setEnabled also clears any stale LaunchAgent plist from older builds so migrating users aren't launched twice. With the Settings checkbox now the single entry point, remove the "Launch at Login" item (and its toggleLogin handler / loginItem property) from the status menu so there's nothing to keep in sync. --- Sources/TermIMS/AppDelegate.swift | 11 ---- Sources/TermIMS/LaunchAtLogin.swift | 52 +++++++++++-------- .../TermIMS/SettingsWindowController.swift | 5 -- 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/Sources/TermIMS/AppDelegate.swift b/Sources/TermIMS/AppDelegate.swift index 107dc4c..30c0c6f 100644 --- a/Sources/TermIMS/AppDelegate.swift +++ b/Sources/TermIMS/AppDelegate.swift @@ -8,7 +8,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var settingsWC: SettingsWindowController? private var permissionWC: PermissionWindowController? private var enabledItem: NSMenuItem! - private var loginItem: NSMenuItem! private var permissionPollTimer: Timer? private var wasTrusted = false @@ -170,12 +169,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { let settings = NSMenuItem(title: "Settings\u{2026}", action: #selector(showSettings), keyEquivalent: ",") settings.target = self; menu.addItem(settings) - menu.addItem(.separator()) - loginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLogin), keyEquivalent: "") - loginItem.target = self - loginItem.state = LaunchAtLogin.isEnabled ? .on : .off - menu.addItem(loginItem) - menu.addItem(.separator()) let quit = NSMenuItem(title: "Quit TermIMS", action: #selector(quitApp), keyEquivalent: "q") quit.target = self; menu.addItem(quit) @@ -194,9 +187,5 @@ class AppDelegate: NSObject, NSApplicationDelegate { if settingsWC == nil { settingsWC = SettingsWindowController() } settingsWC?.showWindow(nil); NSApp.activate(ignoringOtherApps: true) } - @objc private func toggleLogin() { - LaunchAtLogin.setEnabled(!LaunchAtLogin.isEnabled) - loginItem.state = LaunchAtLogin.isEnabled ? .on : .off - } @objc private func quitApp() { monitor.stop(); NSApp.terminate(nil) } } diff --git a/Sources/TermIMS/LaunchAtLogin.swift b/Sources/TermIMS/LaunchAtLogin.swift index 1aaa6ad..434f7db 100644 --- a/Sources/TermIMS/LaunchAtLogin.swift +++ b/Sources/TermIMS/LaunchAtLogin.swift @@ -1,33 +1,39 @@ import Foundation +import ServiceManagement -/// Launch-at-login backed by a LaunchAgent plist in ~/Library/LaunchAgents. -/// The plist's presence is the source of truth for the enabled state, so the -/// status-menu toggle and the Settings checkbox always agree without any -/// stored flag of their own. +/// Launch-at-login via `SMAppService.mainApp` (macOS 13+). Registering the +/// app bundle this way makes it appear in System Settings → General → Login +/// Items under "Open at Login" with the real app icon and name — unlike the +/// legacy LaunchAgent plist, which lands in "Allow in the Background" as a +/// raw `exec` entry from an "unidentified developer". enum LaunchAtLogin { - private static let label = "top.cuiko.termims" - - private static var plistPath: String { - NSHomeDirectory() + "/Library/LaunchAgents/\(label).plist" - } - static var isEnabled: Bool { - FileManager.default.fileExists(atPath: plistPath) + SMAppService.mainApp.status == .enabled } static func setEnabled(_ enabled: Bool) { - let fm = FileManager.default - if enabled { - try? fm.createDirectory(atPath: NSHomeDirectory() + "/Library/LaunchAgents", - withIntermediateDirectories: true) - let plist: NSDictionary = [ - "Label": label, - "ProgramArguments": [Bundle.main.executablePath ?? ""], - "RunAtLoad": true, - ] - plist.write(toFile: plistPath, atomically: true) - } else { - try? fm.removeItem(atPath: plistPath) + // Drop any agent left by older plist-based builds so the user isn't + // launched twice (once by the stale agent, once by SMAppService). + removeLegacyAgent() + do { + if enabled { + if SMAppService.mainApp.status != .enabled { + try SMAppService.mainApp.register() + } + } else { + if SMAppService.mainApp.status == .enabled { + try SMAppService.mainApp.unregister() + } + } + } catch { + Log.debug("LaunchAtLogin set(\(enabled)) failed: \(error)") + } + } + + private static func removeLegacyAgent() { + let path = NSHomeDirectory() + "/Library/LaunchAgents/top.cuiko.termims.plist" + if FileManager.default.fileExists(atPath: path) { + try? FileManager.default.removeItem(atPath: path) } } } diff --git a/Sources/TermIMS/SettingsWindowController.swift b/Sources/TermIMS/SettingsWindowController.swift index 82e9a72..e794383 100644 --- a/Sources/TermIMS/SettingsWindowController.swift +++ b/Sources/TermIMS/SettingsWindowController.swift @@ -251,11 +251,6 @@ class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTab } @objc private func launchAtLoginToggled(_ sender: NSButton) { LaunchAtLogin.setEnabled(sender.state == .on) - // Launch-at-login lives in a plist, not RuleStore, so toggling it - // doesn't otherwise notify anyone. Post the same signal the store - // setters use so the status menu rebuilds and its "Launch at Login" - // item reflects the new state. - NotificationCenter.default.post(name: .rulesDidChange, object: nil) } @objc private func hideIconToggled(_ sender: NSButton) { RuleStore.shared.hideMenuBarIcon = sender.state == .on From 7183902332315d8bc86913002af61aeea8df3a42 Mon Sep 17 00:00:00 2001 From: cuiko Date: Fri, 5 Jun 2026 21:58:21 +0800 Subject: [PATCH 4/4] fix(settings): Re-sync launch-at-login checkbox when window reactivates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The General tab is built once and the window controller is cached, so a launch-at-login change made outside the app — most notably the user removing TermIMS from System Settings → Login Items — left the checkbox showing a stale state on the next open. macOS posts no notification when our login item is toggled externally, so there's nothing to observe. Re-read the live SMAppService status on NSWindow.didBecomeKeyNotification so the checkbox reflects reality every time the Settings window comes forward. --- Sources/TermIMS/SettingsWindowController.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/TermIMS/SettingsWindowController.swift b/Sources/TermIMS/SettingsWindowController.swift index e794383..5f0475e 100644 --- a/Sources/TermIMS/SettingsWindowController.swift +++ b/Sources/TermIMS/SettingsWindowController.swift @@ -78,6 +78,7 @@ final class DragHandleView: NSView { class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTableViewDelegate { private var appTableView: NSTableView! private var termTableView: NSTableView! + private var loginCheck: NSButton? private let inputSources = listInputSources() convenience init() { @@ -90,6 +91,19 @@ class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTab w.isReleasedWhenClosed = false self.init(window: w) buildUI() + // The General tab is built once and the controller is cached, so a + // launch-at-login change made elsewhere (the user toggling our entry + // in System Settings → Login Items) would leave the checkbox stale. + // macOS sends us no notification for that, so re-read the live + // SMAppService status every time the window comes forward. + NotificationCenter.default.addObserver(self, selector: #selector(syncExternalState), + name: NSWindow.didBecomeKeyNotification, object: w) + } + + deinit { NotificationCenter.default.removeObserver(self) } + + @objc private func syncExternalState() { + loginCheck?.state = LaunchAtLogin.isEnabled ? .on : .off } // MARK: Tab Builder @@ -165,6 +179,7 @@ class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTab target: self, action: #selector(launchAtLoginToggled(_:))) loginCheck.translatesAutoresizingMaskIntoConstraints = false loginCheck.state = LaunchAtLogin.isEnabled ? .on : .off + self.loginCheck = loginCheck let hideCheck = NSButton(checkboxWithTitle: "Hide menu bar icon (reopen app to show Settings)", target: self, action: #selector(hideIconToggled(_:)))