From 2eb97c3d622bd914fe871f86cced71281b120684 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Sat, 27 Jun 2026 22:09:40 -0400 Subject: [PATCH 1/3] feat(menubar): survive a hidden menu bar icon instead of quitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SwiftUI `MenuBarExtra`-only app self-terminates the instant its status item is hidden — Apple documents this. The user can hide the icon via Control Center (macOS 26+) or by ⌘-dragging it off the bar, and because that hidden state is *persisted*, an unguarded app re-terminates on every later launch before any UI appears: it looks like a crash and can never be reopened. Veto that one unsolicited `terminate:` in `applicationShouldTerminate` when the icon is hidden, so the lock engine keeps running. Recovery comes from two paths: relaunching the running app re-presents Settings (`applicationShouldHandleReopen`), and a cold launch while the icon is hidden also opens Settings — guarded by `launchAtLoginActive` and the default-launch key so it never pops a window at login. Every *wanted* exit still goes through. Route the explicit Quit, the `lockime://quit` command, and the self-test teardown through a new `AppState.quit()` that sets `terminationRequested`; Sparkle's install-and-relaunch is recognized via `UpdateController.isInstallingUpdate`; and a logout/restart/shutdown is recognized by its `kAEQuitApplication` Apple Event. Settings is opened through the captured `\.openSettings` action (`SettingsActionBridge`) because `showSettingsWindow:` silently no-ops for this accessory app. Signed-off-by: Kevin Cui --- Sources/LockIME/API/URLCommandHandler.swift | 6 +- Sources/LockIME/AppDelegate.swift | 94 ++++++++++++++++++- Sources/LockIME/AppState.swift | 21 +++++ Sources/LockIME/LockIMEApp.swift | 17 ++++ Sources/LockIME/UI/MenuBarView.swift | 5 +- .../LockIME/Updates/UpdateController.swift | 10 ++ 6 files changed, 149 insertions(+), 4 deletions(-) diff --git a/Sources/LockIME/API/URLCommandHandler.swift b/Sources/LockIME/API/URLCommandHandler.swift index 60a9a96..318036f 100644 --- a/Sources/LockIME/API/URLCommandHandler.swift +++ b/Sources/LockIME/API/URLCommandHandler.swift @@ -116,8 +116,10 @@ final class URLCommandHandler { return .success(nil) case .quit: // Defer past the synchronous reply so an x-success callback still - // fires before the app tears down. - Task { @MainActor in NSApp.terminate(nil) } + // fires before the app tears down. Route through `quit()` so the + // termination is flagged as wanted — it must succeed even when the + // menu bar icon is hidden (the terminate guard would otherwise veto). + Task { @MainActor in state.quit() } return .success(nil) // Queries diff --git a/Sources/LockIME/AppDelegate.swift b/Sources/LockIME/AppDelegate.swift index b466405..3e68cb7 100644 --- a/Sources/LockIME/AppDelegate.swift +++ b/Sources/LockIME/AppDelegate.swift @@ -17,18 +17,77 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if ProcessInfo.processInfo.environment["LOCKIME_AXFLOW_TEST"] == "1" { Task { @MainActor in await appState.runAccessibilityGrantSelfTest() - NSApp.terminate(nil) + appState.quit() } return } #endif appState.start() + + // A *user* cold launch while the menu bar icon is hidden would show + // nothing at all — no icon, no Dock tile, no window — leaving the user + // unable to reach the app. Open Settings so they can see it's alive and + // re-enable the icon. The hidden state isn't observable yet at launch (the + // system applies it a beat later — the same action that fires the + // terminate we veto), so re-check after a short delay. Two guards keep + // this off *system* launches: + // • `launchAtLoginActive` — a launch can only be the silent login + // auto-start if the app is registered for it, so registered users never + // get a window popped at login; they recover via the reopen path above + // (their app is normally already running). This is the load-bearing + // login guard. We deliberately don't read the per-launch login Apple + // Event (`keyAELaunchedAsLogInItem`), whose firing under + // `SMAppService.mainApp` is unverified. + // • a non-default launch — opening a file/URL, a Service, state + // restoration — reports `NSApplicationLaunchIsDefaultLaunchKey` false; + // that's the key's documented purpose, and it's all we use it for (it + // is NOT a reliable login-vs-user signal). + let isDefaultLaunch = notification.userInfo?["NSApplicationLaunchIsDefaultLaunchKey"] as? Bool ?? true + guard isDefaultLaunch, !appState.launchAtLoginActive else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + guard let self, self.menuBarIconHidden else { return } + self.openSettings() + } } func applicationWillTerminate(_ notification: Notification) { appState.stop() } + /// Veto the one termination we never asked for. A SwiftUI `MenuBarExtra`-only + /// app self-terminates the instant its status item is hidden — Apple documents + /// this: "An app that only shows in the menu bar will be automatically + /// terminated if the user removes the extra from the menu bar." The user can + /// hide it via the menu-bar/Control-Center settings (macOS 26+) or by + /// ⌘-dragging the icon off the bar; macOS then posts + /// `NSStatusItemChangeVisibilityAction` and AppKit calls `terminate:`. Because + /// that hidden state is *persisted* (a `NSStatusItem Visible…` default), an + /// unguarded app re-terminates on every later launch before any UI appears — + /// it looks like it crashed and can never be reopened. So when the icon is + /// hidden we stay alive: the lock engine keeps running and relaunching reopens + /// Settings (see `applicationShouldHandleReopen`). Every *wanted* exit still + /// goes through — + /// an explicit Quit / `lockime://quit` (`terminationRequested`), a Sparkle + /// install-and-relaunch, and a logout/restart/shutdown (the + /// `kAEQuitApplication` Apple Event). + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + if appState.terminationRequested { return .terminateNow } + if appState.updateController.isInstallingUpdate { return .terminateNow } + if isSystemTerminationEvent { return .terminateNow } + if menuBarIconHidden { return .terminateCancel } + return .terminateNow + } + + /// Relaunching a running app (Finder / Spotlight / Dock / `open`) is the + /// user's way back in when the menu bar icon is hidden — there is no other + /// affordance. Always (re)present Settings; we ignore `hasVisibleWindows`, + /// which counts a *minimized* window as visible and would otherwise leave a + /// minimized Settings window buried with nothing surfaced. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + openSettings() + return true + } + /// Handle `lockime://` (and the Debug `lockime-dev://`) URL-scheme commands. /// LaunchServices delivers only the schemes the app registered in its /// `CFBundleURLTypes`, so each URL is one of ours; the parser keys off the @@ -46,4 +105,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate { urlHandler.handle(url) } } + + // MARK: - Menu bar icon recovery + + /// True when our status item is persisted as hidden. AppKit saves each + /// MenuBarExtra item's visibility in our own defaults domain under an + /// "NSStatusItem Visible…" key (the suffix carries an index and, on recent + /// macOS, a "CC" infix for Control-Center-managed items), so match the family + /// by prefix rather than one literal key. If Apple ever renames it this reads + /// `false` and we fall back to default termination — no crash, no veto. + private var menuBarIconHidden: Bool { + UserDefaults.standard.dictionaryRepresentation().contains { key, value in + key.hasPrefix("NSStatusItem Visible") + && (value as? NSNumber)?.boolValue == false + } + } + + /// Whether the in-flight termination is a logout/restart/shutdown — those + /// carry the `kAEQuitApplication` Apple Event; the status-item-hide + /// `terminate:` does not. Lets the user log out even while our icon is hidden. + private var isSystemTerminationEvent: Bool { + guard let event = NSAppleEventManager.shared().currentAppleEvent else { return false } + // 'aevt' / 'quit' four-char codes, spelled out to avoid a Carbon import. + return event.eventClass == 0x6165_7674 && event.eventID == 0x7175_6974 + } + + /// Open the SwiftUI `Settings` scene. The AppKit `showSettingsWindow:` + /// selector reports success but never actually opens the scene for this + /// accessory app, so go through the captured `\.openSettings` action instead + /// (see `SettingsActionBridge` in `LockIMEApp`). + private func openSettings() { + NSApp.activate(ignoringOtherApps: true) + appState.openSettingsAction?() + } } diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index 8eba558..fa0c373 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -265,6 +265,27 @@ final class AppState { } } + /// Set when the user (menu **Quit** / `lockime://quit`) deliberately asks us + /// to exit, so `AppDelegate.applicationShouldTerminate` can tell a *wanted* + /// quit apart from the unsolicited `terminate:` AppKit fires the moment our + /// menu bar icon is hidden — which must never kill the app. + private(set) var terminationRequested = false + + /// The one sanctioned exit path: flag the termination as wanted, then quit. + /// Works even when the menu bar icon is hidden (the terminate guard lets a + /// flagged termination through). + func quit() { + terminationRequested = true + NSApp.terminate(nil) + } + + /// Bridge to SwiftUI's `\.openSettings` action, captured by a tiny view in the + /// MenuBarExtra label (see `LockIMEApp`). The AppKit `showSettingsWindow:` + /// selector returns success but never actually opens the SwiftUI `Settings` + /// scene for this accessory app, so the recovery path (`AppDelegate`, when the + /// menu bar icon is hidden) calls this instead. + @ObservationIgnored var openSettingsAction: (() -> Void)? + deinit { purgeTask?.cancel() // The shortcut observer is torn down in `stop()`; a nonisolated deinit diff --git a/Sources/LockIME/LockIMEApp.swift b/Sources/LockIME/LockIMEApp.swift index 31f7513..d511065 100644 --- a/Sources/LockIME/LockIMEApp.swift +++ b/Sources/LockIME/LockIMEApp.swift @@ -16,6 +16,7 @@ struct LockIMEApp: App { // snacking on bamboo = unlocked. Monochrome template glyphs so the // system supplies the menu-bar tint (light/dark/active). Image(appState.isLocked ? "TrayLocked" : "TrayUnlocked") + .background(SettingsActionBridge(appState: appState)) } .menuBarExtraStyle(.menu) @@ -27,6 +28,22 @@ struct LockIMEApp: App { } } +/// Captures SwiftUI's `\.openSettings` action into `AppState` so AppKit (the +/// `AppDelegate` menu-bar-icon recovery) can open the `Settings` scene the only +/// way that actually works for this accessory app. Lives in the MenuBarExtra +/// *label* — the one view instantiated at launch even while the icon is hidden — +/// as a zero-size, invisible background. +private struct SettingsActionBridge: View { + let appState: AppState + @Environment(\.openSettings) private var openSettings + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onAppear { appState.openSettingsAction = { openSettings() } } + } +} + private extension View { /// Inject the shared state plus the chosen locale, rebuilding the subtree /// on language change so every string re-resolves live (no restart). diff --git a/Sources/LockIME/UI/MenuBarView.swift b/Sources/LockIME/UI/MenuBarView.swift index d9e2261..a1a6d78 100644 --- a/Sources/LockIME/UI/MenuBarView.swift +++ b/Sources/LockIME/UI/MenuBarView.swift @@ -110,7 +110,10 @@ struct MenuBarView: View { Divider() Button { - NSApplication.shared.terminate(nil) + // Route through AppState so the termination is flagged as wanted — + // the AppDelegate terminate guard otherwise vetoes a bare terminate: + // (it can't tell a real quit from AppKit hiding our status item). + state.quit() } label: { Label("Quit", systemImage: "power") } diff --git a/Sources/LockIME/Updates/UpdateController.swift b/Sources/LockIME/Updates/UpdateController.swift index 42d25dc..ae8eef8 100644 --- a/Sources/LockIME/Updates/UpdateController.swift +++ b/Sources/LockIME/Updates/UpdateController.swift @@ -39,6 +39,16 @@ final class UpdateController { /// "Last checked" line in the Updates pane. var lastCheckDate: Date? { updater?.lastUpdateCheckDate } + /// True while an update is installing/relaunching. The app's terminate guard + /// consults this so Sparkle's install-and-relaunch is never vetoed — even when + /// the menu bar icon is hidden (when a bare `terminate:` would be cancelled). + var isInstallingUpdate: Bool { + switch model.phase { + case .readyToInstall, .installing: return true + default: return false + } + } + @ObservationIgnored private let driver: LockIMEUserDriver @ObservationIgnored private let updaterDelegate = UpdaterDelegate() @ObservationIgnored private var updater: SPUUpdater? From 4b792a9e4bd509b3d6188e4bd2e2a3cb27eb4b25 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Sat, 27 Jun 2026 23:08:23 -0400 Subject: [PATCH 2/3] feat(menubar): add a setting to hide the menu bar icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit taught the app to survive an *unwanted* hide of its status item; this adds an explicit General ▸ Menu Bar toggle so the user can hide it on purpose. The lock engine keeps running with the icon gone, and relaunching re-presents Settings. Store it as a per-device preference (`menuBarIconHidden`) in its own `UserDefaults` key, deliberately outside `LockConfiguration` so it never travels through config export/import — the same treatment as `apiEnabled`. `setMenuBarIconHidden` is the single write path. Drive `MenuBarExtra(isInserted:)` from an `@AppStorage` mirror of that key rather than the `@Observable` flag directly: an `@Observable` read inside a `Binding`'s `get` closure doesn't register with the `App`'s scene graph, so the scene would never re-evaluate when the flag flips. `@AppStorage` is the `DynamicProperty` that does, and routing its `set` back through `AppState` keeps one source of truth. Extend the terminate veto and the cold-launch Settings reveal to honor either signal — the system-persisted hide (now `statusItemPersistedHidden`) or the in-app toggle (`appState.menuBarIconHidden`) — because a programmatic hide via `isInserted` doesn't write the system visibility default the old guard watched. Every user-facing string is translated for all supported languages. Signed-off-by: Kevin Cui --- Sources/LockIME/AppDelegate.swift | 24 ++- Sources/LockIME/AppState.swift | 23 +++ Sources/LockIME/Localizable.xcstrings | 156 ++++++++++++++++++ Sources/LockIME/LockIMEApp.swift | 18 +- .../UI/Settings/GeneralSettingsPane.swift | 14 ++ 5 files changed, 225 insertions(+), 10 deletions(-) diff --git a/Sources/LockIME/AppDelegate.swift b/Sources/LockIME/AppDelegate.swift index 3e68cb7..d00b650 100644 --- a/Sources/LockIME/AppDelegate.swift +++ b/Sources/LockIME/AppDelegate.swift @@ -45,7 +45,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let isDefaultLaunch = notification.userInfo?["NSApplicationLaunchIsDefaultLaunchKey"] as? Bool ?? true guard isDefaultLaunch, !appState.launchAtLoginActive else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - guard let self, self.menuBarIconHidden else { return } + // Reveal for either hidden state: a system hide (the persisted default, + // only observable a beat after launch) or the in-app "Hide menu bar + // icon" toggle (known immediately, but re-checked here all the same). + guard let self, self.statusItemPersistedHidden || self.appState.menuBarIconHidden else { return } self.openSettings() } } @@ -74,7 +77,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if appState.terminationRequested { return .terminateNow } if appState.updateController.isInstallingUpdate { return .terminateNow } if isSystemTerminationEvent { return .terminateNow } - if menuBarIconHidden { return .terminateCancel } + if statusItemPersistedHidden || appState.menuBarIconHidden { return .terminateCancel } return .terminateNow } @@ -108,13 +111,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Menu bar icon recovery - /// True when our status item is persisted as hidden. AppKit saves each - /// MenuBarExtra item's visibility in our own defaults domain under an - /// "NSStatusItem Visible…" key (the suffix carries an index and, on recent - /// macOS, a "CC" infix for Control-Center-managed items), so match the family - /// by prefix rather than one literal key. If Apple ever renames it this reads - /// `false` and we fall back to default termination — no crash, no veto. - private var menuBarIconHidden: Bool { + /// True when the icon was hidden *by the system* — the user ⌘-dragged it off + /// the bar or hid it in Control Center. AppKit saves each MenuBarExtra item's + /// visibility in our own defaults domain under an "NSStatusItem Visible…" key + /// (the suffix carries an index and, on recent macOS, a "CC" infix for + /// Control-Center-managed items), so match the family by prefix rather than one + /// literal key. If Apple ever renames it this reads `false` and we fall back to + /// default termination — no crash, no veto. The *in-app* "Hide menu bar icon" + /// toggle is tracked separately by `appState.menuBarIconHidden`; the guards + /// above honor either signal. + private var statusItemPersistedHidden: Bool { UserDefaults.standard.dictionaryRepresentation().contains { key, value in key.hasPrefix("NSStatusItem Visible") && (value as? NSNumber)?.boolValue == false diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index fa0c373..1ccac44 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -29,6 +29,18 @@ final class AppState { private(set) var apiEnabled: Bool = false @ObservationIgnored private static let apiEnabledKey = "apiEnabled" + /// Whether the user has hidden LockIME's menu-bar icon (Settings ▸ General ▸ + /// Menu Bar). Per-device like `apiEnabled` — its own `UserDefaults` key, + /// deliberately **not** part of `LockConfiguration`, so it never travels + /// through config export/import. Drives the `MenuBarExtra`'s `isInserted` + /// binding (mirrored by an `@AppStorage` on the same key so the scene reacts + /// live). Hiding does not stop the app: the lock engine keeps running, the + /// AppDelegate terminate guard treats a hidden icon as a sanctioned state, and + /// relaunching re-presents Settings. The key is non-private so the scene's + /// `@AppStorage` mirror can name it. + private(set) var menuBarIconHidden: Bool = false + @ObservationIgnored static let menuBarIconHiddenKey = "menuBarIconHidden" + /// The configured global toggle-lock shortcut, mirrored as observable state /// so the menu-bar header re-renders the moment the user binds or clears it /// in Settings (a plain `getShortcut` read isn't tracked by `@Observable`). @@ -113,6 +125,7 @@ final class AppState { init() { languagePreference = .load() apiEnabled = UserDefaults.standard.bool(forKey: Self.apiEnabledKey) // absent ⇒ false (opt-in) + menuBarIconHidden = UserDefaults.standard.bool(forKey: Self.menuBarIconHiddenKey) // absent ⇒ false (icon shown) ThirdPartyBundleLocalization.apply(language: languagePreference.effectiveLanguage) } @@ -123,6 +136,16 @@ final class AppState { UserDefaults.standard.set(enabled, forKey: Self.apiEnabledKey) } + /// Hide or show LockIME's menu-bar icon. Persisted immediately (the key the + /// `MenuBarExtra` `isInserted` mirror watches) so the change takes effect live + /// and survives relaunch. This is the single write path for the preference — + /// the scene's `@AppStorage` mirror and the terminate/reveal guards all read + /// back through here — so the cached value the guards see is always fresh. + func setMenuBarIconHidden(_ hidden: Bool) { + menuBarIconHidden = hidden + UserDefaults.standard.set(hidden, forKey: Self.menuBarIconHiddenKey) + } + /// GitHub URL of the URL-scheme API reference, in the app's current language /// (mirrors the `docs/URL-Scheme-API/README..md` naming). Points at /// `main`, so it resolves once this work lands there. diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index a373e87..462779a 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -3482,6 +3482,162 @@ } } }, + "Hide menu bar icon": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏菜单栏图标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏選單列圖示" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニューバーアイコンを隠す" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer l’icône de la barre des menus" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol in der Menüleiste ausblenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar el icono de la barra de menús" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ocultar o ícone da barra de menus" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть значок в строке меню" + } + } + } + }, + "LockIME keeps running in the background with its icon hidden. To show this window again, open LockIME from the Applications folder or Spotlight.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏图标后,LockIME 仍在后台运行。要再次显示此窗口,请从「应用程序」文件夹或 Spotlight 打开 LockIME。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏圖示後,LockIME 仍在背景執行。若要再次顯示此視窗,請從「應用程式」資料夾或 Spotlight 開啟 LockIME。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アイコンを隠しても、LockIME はバックグラウンドで動作し続けます。このウィンドウを再び表示するには、「アプリケーション」フォルダまたは Spotlight から LockIME を開いてください。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "LockIME continue de fonctionner en arrière-plan, son icône masquée. Pour afficher à nouveau cette fenêtre, ouvrez LockIME depuis le dossier Applications ou Spotlight." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "LockIME läuft mit ausgeblendetem Symbol im Hintergrund weiter. Um dieses Fenster wieder anzuzeigen, öffne LockIME über den Ordner „Programme“ oder Spotlight." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "LockIME sigue funcionando en segundo plano con su icono oculto. Para volver a mostrar esta ventana, abre LockIME desde la carpeta Aplicaciones o desde Spotlight." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O LockIME continua a funcionar em segundo plano com o ícone oculto. Para mostrar esta janela novamente, abra o LockIME a partir da pasta Aplicações ou do Spotlight." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "LockIME продолжает работать в фоне со скрытым значком. Чтобы снова показать это окно, откройте LockIME из папки «Программы» или через Spotlight." + } + } + } + }, + "Menu Bar": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "菜单栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選單列" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニューバー" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Barre des menus" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Menüleiste" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Barra de menús" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Barra de menus" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Строка меню" + } + } + } + }, "Lock input source": { "localizations": { "zh-Hans": { diff --git a/Sources/LockIME/LockIMEApp.swift b/Sources/LockIME/LockIMEApp.swift index d511065..a09a7a1 100644 --- a/Sources/LockIME/LockIMEApp.swift +++ b/Sources/LockIME/LockIMEApp.swift @@ -5,10 +5,26 @@ import SwiftUI struct LockIMEApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + /// Scene-reactive mirror of `AppState.menuBarIconHidden` (same `UserDefaults` + /// key). `@Observable` reads inside a `Binding`'s `get` closure don't register + /// with the `App`'s scene graph, so the scene would never re-evaluate when the + /// flag flips; `@AppStorage` is the `DynamicProperty` that does. `AppState` + /// still owns the writes — the `set` below and the General toggle both route + /// through `setMenuBarIconHidden`, and this mirror just follows the key via + /// UserDefaults observation — so there is one source of truth, not two. + @AppStorage(AppState.menuBarIconHiddenKey) private var menuBarIconHidden = false + private var appState: AppState { delegate.appState } var body: some Scene { - MenuBarExtra { + // `isInserted: false` removes the status item; the app survives it (see + // `AppDelegate.applicationShouldTerminate`). Route the write through + // `AppState` so the cached value its terminate/reveal guards read stays in + // step with the persisted key. + MenuBarExtra(isInserted: Binding( + get: { !menuBarIconHidden }, + set: { appState.setMenuBarIconHidden(!$0) } + )) { MenuBarView() .localized(with: appState) } label: { diff --git a/Sources/LockIME/UI/Settings/GeneralSettingsPane.swift b/Sources/LockIME/UI/Settings/GeneralSettingsPane.swift index 53589a6..b0b2395 100644 --- a/Sources/LockIME/UI/Settings/GeneralSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/GeneralSettingsPane.swift @@ -57,6 +57,20 @@ struct GeneralSettingsPane: View { } } + Section { + let hideIconBinding = Binding( + get: { state.menuBarIconHidden }, + set: { newValue in + withAnimation(DS.Motion.toggle) { state.setMenuBarIconHidden(newValue) } + } + ) + Toggle("Hide menu bar icon", isOn: hideIconBinding) + } header: { + Text("Menu Bar") + } footer: { + SectionFooter("LockIME keeps running in the background with its icon hidden. To show this window again, open LockIME from the Applications folder or Spotlight.") + } + Section { let languageBinding = Binding( get: { state.languagePreference }, From 509c004f0dc9e4275b6477e8315d56188d023c4e Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Sat, 27 Jun 2026 23:22:25 -0400 Subject: [PATCH 3/3] fix(menubar): keep the hidden-icon veto during the update-ready prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isInstallingUpdate` also returned true for `.readyToInstall`, the "install and relaunch?" prompt that waits on the user and can stay up indefinitely. While it showed, a status-item hide would hit the `isInstallingUpdate` short-circuit in `applicationShouldTerminate` and terminate the app instead of being vetoed — losing the survival behavior during that window. Limit it to `.installing`, the phase entered only once the user has committed to the relaunch (`LockIMEUserDriver.showInstallingUpdate`), so the ready-to-install prompt now stays protected by the hidden-icon veto. Signed-off-by: Kevin Cui --- Sources/LockIME/Updates/UpdateController.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/LockIME/Updates/UpdateController.swift b/Sources/LockIME/Updates/UpdateController.swift index ae8eef8..7b3ba28 100644 --- a/Sources/LockIME/Updates/UpdateController.swift +++ b/Sources/LockIME/Updates/UpdateController.swift @@ -39,12 +39,18 @@ final class UpdateController { /// "Last checked" line in the Updates pane. var lastCheckDate: Date? { updater?.lastUpdateCheckDate } - /// True while an update is installing/relaunching. The app's terminate guard - /// consults this so Sparkle's install-and-relaunch is never vetoed — even when - /// the menu bar icon is hidden (when a bare `terminate:` would be cancelled). + /// True while an update is actually installing/relaunching. The app's + /// terminate guard consults this so Sparkle's install-and-relaunch is never + /// vetoed — even when the menu bar icon is hidden (when a bare `terminate:` + /// would be cancelled). Deliberately *not* `.readyToInstall`: that phase is the + /// "install and relaunch?" prompt waiting on the user, which can stay up + /// indefinitely, and during it a status-item hide must still keep the app alive + /// (the hidden-icon veto) rather than be mistaken for a sanctioned relaunch. + /// `.installing` is entered only once the user has committed (see + /// `LockIMEUserDriver.showInstallingUpdate`). var isInstallingUpdate: Bool { switch model.phase { - case .readyToInstall, .installing: return true + case .installing: return true default: return false } }