diff --git a/Sources/TermIMS/AppDelegate.swift b/Sources/TermIMS/AppDelegate.swift index e0712b7..30c0c6f 100644 --- a/Sources/TermIMS/AppDelegate.swift +++ b/Sources/TermIMS/AppDelegate.swift @@ -8,14 +8,9 @@ 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 - private var launchAgentPath: String { - NSHomeDirectory() + "/Library/LaunchAgents/top.cuiko.termims.plist" - } - func applicationDidFinishLaunching(_ n: Notification) { Log.debug("=== TermIMS started ===") installEditMenu() @@ -174,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 = FileManager.default.fileExists(atPath: launchAgentPath) ? .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) @@ -198,20 +187,5 @@ class AppDelegate: NSObject, NSApplicationDelegate { if settingsWC == nil { settingsWC = SettingsWindowController() } 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 - } @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..434f7db --- /dev/null +++ b/Sources/TermIMS/LaunchAtLogin.swift @@ -0,0 +1,39 @@ +import Foundation +import ServiceManagement + +/// 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 { + static var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + + static func setEnabled(_ enabled: Bool) { + // 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 38f0ddb..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 @@ -161,6 +175,12 @@ 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 + self.loginCheck = loginCheck + let hideCheck = NSButton(checkboxWithTitle: "Hide menu bar icon (reopen app to show Settings)", target: self, action: #selector(hideIconToggled(_:))) hideCheck.translatesAutoresizingMaskIntoConstraints = false @@ -181,7 +201,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 +230,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 +264,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 }