Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Sources/LockIME/API/URLCommandHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 99 additions & 1 deletion Sources/LockIME/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,80 @@ 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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()
}
}

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 statusItemPersistedHidden || appState.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
Expand All @@ -46,4 +108,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
urlHandler.handle(url)
}
}

// MARK: - Menu bar icon recovery

/// 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
}
}

/// 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?()
}
}
44 changes: 44 additions & 0 deletions Sources/LockIME/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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)
}

Expand All @@ -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.<code>.md` naming). Points at
/// `main`, so it resolves once this work lands there.
Expand Down Expand Up @@ -265,6 +288,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
Expand Down
156 changes: 156 additions & 0 deletions Sources/LockIME/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading