Skip to content

feat(menubar): survive a hidden icon and let the user hide it#40

Merged
BlackHole1 merged 3 commits into
mainfrom
feat/menubar-hidden-survival
Jun 28, 2026
Merged

feat(menubar): survive a hidden icon and let the user hide it#40
BlackHole1 merged 3 commits into
mainfrom
feat/menubar-hidden-survival

Conversation

@BlackHole1

@BlackHole1 BlackHole1 commented Jun 28, 2026

Copy link
Copy Markdown
Member

A SwiftUI MenuBarExtra-only app self-terminates the instant its status item is hidden — Apple documents this — 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. This branch makes a hidden icon a survivable, and now deliberate, state.

First, it vetoes that one unsolicited terminate: in applicationShouldTerminate so the lock engine keeps running, and gives the user a way back: relaunching the app re-presents Settings (applicationShouldHandleReopen), and a non-login cold launch while hidden reveals Settings too. Every wanted exit still goes through — an explicit Quit / lockime://quit (routed through a new AppState.quit() that flags the termination), a Sparkle install-and-relaunch, and a logout/restart/shutdown (its kAEQuitApplication Apple Event). Because showSettingsWindow: silently no-ops for this accessory app, Settings is opened through the captured \.openSettings action (SettingsActionBridge).

On top of that, it adds an explicit General ▸ Menu Bar toggle so the user can hide the icon on purpose, with the same keep-running-and-recover behavior. A few load-bearing details:

  • The preference (menuBarIconHidden) lives in its own UserDefaults key, outside LockConfiguration, so it never travels through config export/import — the same treatment as apiEnabled.
  • MenuBarExtra(isInserted:) is driven from an @AppStorage mirror of that key, not 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. Writes still route through AppState, keeping one source of truth.
  • The terminate veto and the cold-launch reveal now honor either signal — the system-persisted hide (statusItemPersistedHidden) or the in-app toggle — because a programmatic hide via isInserted doesn't write the system visibility default the original guard watched.

Every user-facing string is translated for all supported languages. I verified the framework behavior directly, since it's subtle: isInserted: false flips the underlying NSStatusItem.isVisible to false (the icon really disappears), the app survives the hide, and the \.openSettings bridge is still captured even on a cold launch that starts hidden — so recovery works in every case.

fixed: #38

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 <bh@bugs.cc>
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 <bh@bugs.cc>
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 86b2d3f2-65b7-461c-ac13-e774a7176026

📥 Commits

Reviewing files that changed from the base of the PR and between 4b792a9 and 509c004.

📒 Files selected for processing (1)
  • Sources/LockIME/Updates/UpdateController.swift

Summary by CodeRabbit

  • New Features
    • Added a “Hide menu bar icon” setting, including a menu bar label section and explanatory footer on how to reopen Settings.
  • Bug Fixes
    • Improved quit/update behavior to use an intentional exit path, preventing the app from being blocked from showing Settings when the icon is hidden.
    • Enhanced startup recovery and simplified reopen handling to reliably open Settings when the menu bar icon isn’t visible.
  • Localization
    • Added new localized strings for the menu bar visibility toggle and help text.

Walkthrough

The PR adds a menu-bar icon hide preference. AppState now stores menuBarIconHidden, persists it, tracks terminationRequested, exposes quit(), and holds an openSettingsAction callback. LockIMEApp mirrors the preference into MenuBarExtra insertion state and captures SwiftUI’s openSettings action. AppDelegate adds hidden-icon recovery, termination guards, and reopen-to-Settings behavior. GeneralSettingsPane adds a Menu Bar section, and quit actions in MenuBarView and URLCommandHandler now route through AppState.quit().

Sequence Diagrams

sequenceDiagram
  participant AppDelegate
  participant AppState
  participant LockIMEApp
  participant SettingsActionBridge
  participant SwiftUIOpenSettings

  LockIMEApp->>SettingsActionBridge: attach background view
  SettingsActionBridge->>AppState: set openSettingsAction
  AppDelegate->>AppState: openSettingsAction?()
  AppState->>SwiftUIOpenSettings: invoke openSettings
Loading
sequenceDiagram
  participant MenuBarView
  participant URLCommandHandler
  participant AppState
  participant AppDelegate

  MenuBarView->>AppState: quit()
  URLCommandHandler->>AppState: quit()
  AppState->>AppDelegate: applicationShouldTerminate
  AppDelegate-->>AppState: allow termination
Loading
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the required type(scope): subject format and clearly describes the hidden menubar icon feature.
Description check ✅ Passed The description is directly related to the menu bar icon hide/recovery changes in this pull request.
Linked Issues check ✅ Passed The PR implements hiding the LockIME menu bar icon and prevents termination when it is hidden, matching issue #38.
Out of Scope Changes check ✅ Passed The changes stay within the hidden-icon and recovery work, including the update-flow guard noted in the PR objectives.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/menubar-hidden-survival

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/LockIME/AppDelegate.swift`:
- Around line 45-47: The launch gating in
AppDelegate.applicationDidFinishLaunching is using the persisted Launch at Login
preference as if it were a per-launch signal, which can incorrectly suppress the
recovery window on a manual default launch. Update the isDefaultLaunch/guard
logic to rely only on a verified current-login auto-start indicator for this
launch, and keep the Settings-opening path in the DispatchQueue.main.asyncAfter
block available when the app is manually opened while hidden.

In `@Sources/LockIME/Updates/UpdateController.swift`:
- Around line 45-48: The isInstallingUpdate flag in UpdateController is too
broad because it treats .readyToInstall as an active install phase, which lets
applicationShouldTerminate bypass the hidden-icon guard too early. Tighten the
logic in isInstallingUpdate so it only returns true for the actual
install/relaunch phase (for example, .installing) unless you can guarantee
.readyToInstall only occurs after user-confirmed relaunch. Keep the change
localized to UpdateController and the phase check that
applicationShouldTerminate relies on.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 247f1f96-255b-4d06-9f72-8afb5818d5c6

📥 Commits

Reviewing files that changed from the base of the PR and between 1bb5723 and 4b792a9.

📒 Files selected for processing (8)
  • Sources/LockIME/API/URLCommandHandler.swift
  • Sources/LockIME/AppDelegate.swift
  • Sources/LockIME/AppState.swift
  • Sources/LockIME/Localizable.xcstrings
  • Sources/LockIME/LockIMEApp.swift
  • Sources/LockIME/UI/MenuBarView.swift
  • Sources/LockIME/UI/Settings/GeneralSettingsPane.swift
  • Sources/LockIME/Updates/UpdateController.swift

Comment thread Sources/LockIME/AppDelegate.swift
Comment thread Sources/LockIME/Updates/UpdateController.swift
`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 <bh@bugs.cc>
@BlackHole1 BlackHole1 merged commit d4879ce into main Jun 28, 2026
3 checks passed
@BlackHole1 BlackHole1 deleted the feat/menubar-hidden-survival branch June 28, 2026 03:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

建议:增加 隐藏菜单栏中的 LockIME 图标 功能

1 participant