Skip to content

feat(macos): launcher identity, Dock tile, hide-to-tray, native menu-bar item#168

Merged
hieshima merged 5 commits into
TomBadash:masterfrom
hughesyadaddy:feat/macos-app-shell
May 15, 2026
Merged

feat(macos): launcher identity, Dock tile, hide-to-tray, native menu-bar item#168
hieshima merged 5 commits into
TomBadash:masterfrom
hughesyadaddy:feat/macos-app-shell

Conversation

@hughesyadaddy
Copy link
Copy Markdown
Contributor

@hughesyadaddy hughesyadaddy commented May 14, 2026

Why

A first-time macOS install of Mouser has four rough edges that make
the app feel half-finished even though every feature works:

  1. Process name is "python". In Activity Monitor and the Force
    Quit dialog the app appears as python (or Python), which is
    indistinguishable from any random script and makes "what's using
    my mouse?" diagnostics annoying.
  2. Dock tile is the generic Python rocket. The committed
    images/AppIcon.icns ships to PyInstaller, but the running
    process's executable identity does not match the bundle, so AppKit
    falls back to the framework default at runtime.
  3. Window closes quit the app. Mouser is intended to live in the
    menu-bar tray; default Qt close behaviour kills the engine along
    with the window. A user who hits Cmd-Q out of habit has to relaunch
    to get scroll inversion / button remapping back.
  4. Menu-bar tray icon does not render on macOS 14+ notched
    MacBooks
    . Qt 6's QSystemTrayIcon allocates a fixed-length
    NSStatusItem and places it left-of-notch, where macOS Sonoma /
    Sequoia refuse to composite items competing with the notch
    cut-out — the result is a phantom item that exists in the status
    bar's geometry but never paints. On a clean install of Mouser on a
    14"/16" MacBook Pro, the tray icon is simply not there. Right-
    click also fails since AppKit cannot deliver clicks to an item it
    refuses to draw.

None of these block functionality, but together they cost the app
roughly 30 seconds of credibility every time someone installs it,
and (4) is a hard-blocker for hide-to-tray on any new MacBook.

What changed

main_qml.py is the only file touched. All changes are macOS-only
(gated on sys.platform == "darwin") and inert on Windows / Linux.

Process identity & Dock presence

  • _macos_named_executable_path() + _maybe_relaunch_with_mouser_process_name()
    — on a source-checkout launch, re-execs the interpreter through a
    stable Mouser-named symlink in
    ~/Library/Application Support/Mouser/. Activity Monitor, ps,
    and the Force Quit dialog then show the process as Mouser. The
    symlink is created with os.symlink and rotated with os.replace
    so a stale link can't break a re-exec. Guarded on sys.frozen so
    packaged .app bundles skip the dance (the bundle's Info.plist
    already pins the name).
  • _rename_macos_bundle_for_dock() — writes Mouser to the
    process's runtime CFBundleName via NSBundle.localizedInfoDictionary
    so the Dock menu's app-name label matches the process name.
  • _install_macos_dock_icon() — decodes images/AppIcon.icns into
    an NSImage exactly once and assigns it via
    NSApplication.setApplicationIconImage_. Module-scope cache so
    visibility transitions don't re-decode the 1024 PNG.
  • _app_icon() — verifies the icon path exists before handing it
    to QIcon so a missing asset surfaces in logs rather than silently
    falling back to the generic icon.

Window-lifecycle <-> Dock tile reconciliation

  • _set_macos_activation_policy(regular: bool) — flips
    NSApp.activationPolicy between NSApplicationActivationPolicyRegular
    (Dock tile visible, Cmd-Tab eligible) and
    NSApplicationActivationPolicyAccessory (menu-bar-only) in response
    to QWindow.visibilityChanged. Idempotency check inside so we don't
    poke AppKit on every redraw.
  • _on_window_visibility_changed(visibility) — Qt signal handler
    that calls the policy setter. Critical fix: the handler is now
    invoked with the window's initial visibility immediately after
    connecting, because the window was created visible: !launchHidden
    by QML before the handler was wired, which meant the initial
    show-transition fired with no listener and the policy stayed
    Accessory (no Dock tile) on a --show-window launch. Without
    this reconcile, a user starting Mouser with the window open would
    never see a Dock tile.

Native NSStatusItem (menu-bar tray icon)

This is the biggest piece in the diff and the reason hide-to-tray is
actually usable on macOS 14+.

  • _install_native_macos_status_item(qmenu, on_left_click)
    installs an NSStatusItem via PyObjC directly, bypassing
    QSystemTrayIcon:
    • Uses NSVariableStatusItemLength instead of
      NSSquareStatusItemLength. Variable-length items are the only
      ones macOS Sonoma+ will composite right-of-notch on a notched
      display, so the icon ends up visible instead of stranded in the
      left-of-notch dead zone Qt's QSystemTrayIcon lands in.
    • Renders the menu-bar glyph as a template image: takes the same
      SVG the in-app sidebar brand mark uses, paints it through
      QSvgRenderer into a QPixmap, exports PNG bytes, hands them to
      NSImage, sets template = True. macOS then tints the icon to
      match the menu bar (light / dark / accent) without a per-mode
      asset.
    • Wires clicks back to Qt with a small Objective-C target subclass
      (_StatusItemTarget(NSObject)): single click → on_left_click()
      (which shows the main window); any modifier or right-click →
      pop up the existing Qt QMenu at the cursor.
  • Qt's QSystemTrayIcon is kept alive but hidden: it remains
    available for showMessage() notifications (which use a different
    AppKit path under the hood, separate from NSStatusItem) so the
    "Mouser is hidden -- click the menu-bar icon to bring it back"
    banner still fires.
  • The native item is retained at module scope so ARC doesn't reclaim
    it the moment the install function returns.

TCC

  • _check_accessibility() — fails closed: a TCC exception or a
    False return from AXIsProcessTrusted now triggers the system
    prompt rather than silently assuming permission was granted.
    Previously a bare except: swallowed the error and the engine ran
    with no event-tap input, which presents to the user as "Mouser is
    running but nothing works".

Why a native NSStatusItem instead of fixing Qt

QSystemTrayIcon calls [NSStatusBar systemStatusBar statusItemWithLength: NSSquareStatusItemLength] internally (verified
against Qt 6.7 source). That length value is the root cause: macOS
Sonoma changed the menu-bar layout to position fixed-square items
left-of-notch on notched displays, where the system refuses to draw
them. There is no Qt API to override the length parameter — the only
fix is to stop using QSystemTrayIcon for the icon surface and call
AppKit directly with NSVariableStatusItemLength. We keep Qt's
QSystemTrayIcon alive in parallel because it owns the
notification-banner path, which works correctly regardless of the
status-item length.

Scope

  • No new dependencies. PyObjC is already required by Mouser on macOS
    (it backs the existing event-tap and HID code).
  • All upstream icon assets unchanged (verified byte-for-byte against
    upstream/master). The Dock-tile art refresh is in feat(brand): macOS-spec app icon and reproducible build pipeline #177 and
    lands independently of this PR.
  • No Info.plist changes — this PR drives everything from the running
    process so the source-checkout and bundle paths share one code
    path.

Test plan

  • Source-checkout launch: Activity Monitor and ps show the
    process as Mouser. The Dock tile shows the Mouser icon. About
    panel surfaces the bundle identity, build mode, HEAD commit,
    and launch path.
  • Frozen .app launch: _maybe_relaunch_with_mouser_process_name
    is skipped (sys.frozen guard); identity comes from the bundle.
  • Toggle window via tray "Show" / "Hide": activation policy
    flips between Regular and Accessory; Dock tile appears /
    disappears in lockstep.
  • --show-window launch on a clean profile (window starts
    visible): Dock tile is present immediately (regression test
    for the initial-visibility reconcile fix).
  • Menu-bar icon on macOS 14.5 / 15.0 notched MacBook Pro:
    icon renders right-of-notch. Single click reopens the window;
    right-click and Option-click pop up the Qt menu.
  • Menu-bar icon on a non-notched display: icon renders, click
    behaviour identical.
  • Revoke Accessibility permission → relaunch → TCC prompt fires
    (regression test for the silent-failure path).
  • pytest tests/ -- 406 tests, 155 subtests, green.

Screenshot

About panel from the sidebar info button -- confirms the running
bundle identity, the Source checkout build mode, the HEAD commit,
and the runtime launch path (all wired through the renamed launcher
machinery):

About panel

A bare `python main_qml.py` launch surfaces "python" in the Dock tile,
Cmd+Tab caption, Force Quit, and Activity Monitor because macOS reads
the Mach-O image basename at exec time and there is no in-process API
to rename it after the fact. The application icon comes from the same
bundle identity, so the Dock also shows the generic Python launcher
icon instead of Mouser's branding. None of this affects PyInstaller
`.app` bundles -- only source and venv launches.

The fix layers a few small pieces in main_qml.py.
`_maybe_relaunch_with_mouser_process_name` re-execs the current
interpreter through a stable symlink whose basename is `Mouser`, staged
atomically via os.replace() to avoid a TOCTOU window where a concurrent
launch could observe the launcher path missing. The symlink lives next
to the venv's python shim so pyvenv.cfg discovery still resolves
site-packages after the re-exec. It returns immediately on
PyInstaller-frozen bundles (where the Mach-O is already correctly
named), on non-macOS platforms, and when the env-var guard shows we
already relaunched. `_rename_macos_bundle_for_dock` mutates
[NSBundle mainBundle]'s display-name keys so the application menu,
Force Quit, and notification banners read "Mouser" as well.

`_install_macos_dock_icon` replaces NSApplication's icon with
`images/logo_icon.png`. The decoded NSImage is cached at module scope
so repeated invocations (one per Regular promotion) only re-issue the
cheap setApplicationIconImage_ syscall instead of re-decoding the
1024px PNG. `_app_icon` similarly hands QIcon the full-resolution PNG
directly so the Dock and Cmd+Tab tiles render crisply at native size
instead of the previous upscaled-256px blur.

`_configure_macos_app_mode` and `_set_macos_activation_policy` launch
the app as NSApplicationActivationPolicyAccessory (menu-bar only, no
Dock entry), then promote to Regular when a window becomes visible.
The visibilityChanged hook flips back to Accessory on hide so the Dock
collapses cleanly. The policy setter is idempotent against rapid
visibilityChanged storms by skipping the AppKit round-trip when the
requested state matches the last-applied one.

`_check_accessibility` fails closed on exception now (returns False)
so future callers can rely on the docstring contract, and
`_parse_cli_args` returns a 4-tuple so callers can branch on the new
`--show-window` flag.
@hughesyadaddy hughesyadaddy force-pushed the feat/macos-app-shell branch from ec9a76f to 1626dc3 Compare May 14, 2026 19:37
@hughesyadaddy hughesyadaddy changed the title feat(macos): launcher rename, Dock icon, hide-to-tray window lifecycle feat(macos): launcher identity, Dock tile, hide-to-tray, native menu-bar item May 14, 2026
@hieshima
Copy link
Copy Markdown
Collaborator

hieshima commented May 15, 2026

Thanks @hughesyadaddy. I pushed four follow-up commits on top of your original commit to tighten the macOS app-shell behavior before merge. Your original commit is preserved.

What changed:

  • Native status item routing now keeps the tray menu reachable from the AppKit icon, so users still have access to Open Settings / Toggle Remapping / Check Updates / Quit after Qt's tray surface is hidden. Mechanism: the PyObjC target class is module-scoped, the AppKit button listens for left/right/other mouse-down events, plain left click opens the settings window, and right-click/control-click/option-click pops the existing Qt tray menu.
  • Cmd-Q / app-menu Quit now hides to tray instead of terminating Mouser. The tray menu's Quit Mouser action is still the explicit full-exit path.
  • macOS logout/restart/shutdown is allowed through. The quit filter now checks the AppleEvent kAEQuitReason (why?) and only bypasses hide-to-tray for SDK-known system/session quit reasons.
  • --start-hidden no longer depends on a function-local QTimer import; tray-minimized notification scheduling is centralized and covered by tests.
  • Accessibility fail-closed behavior is now wired into startup: if macOS does not grant Accessibility, Mouser keeps the UI open but does not start the remapping engine.
  • The AppKit/notch comments were softened so we do not overstate undocumented Apple behavior. The implementation still avoids Qt's fixed-square status-item path and uses a native variable-length status item.
  • Added hermetic tests for the launcher path, re-exec guards, native status-item event routing, quit/session filtering, tray-minimized startup, and Accessibility-gated engine startup.

Validation run on my Mac:

  • python -m py_compile main_qml.py core/*.py ui/*.py
  • git diff --check
  • pytest tests/test_macos_app_shell.py tests/test_single_instance.py tests/test_accessibility.py -> 29 passed
  • full pytest -> 424 passed
  • main_qml.py --start-hidden source smoke: process stayed running, no NameError, engine started.
  • main_qml.py --show-window source smoke: engine started normally.
  • Real Cmd-Q smoke: after engine startup, Cmd-Q left the Mouser process running, confirming the hide-to-tray filter path.
  • App-directed Quit AppleEvent smoke (tell application "Mouser" to quit): process stayed running, confirming ordinary app Quit still hides to tray.

Manual notched-MacBook smoke passed after the follow-up commits:

  • Source-checkout launch re-execed through .venv/bin/Mouser, loaded QML, created the CGEventTap, and started the engine.
  • Native menu-bar icon was visible on the notched MacBook.
  • Left click opened the settings window.
  • Right-click/control-click/option-click opened the existing tray menu.
  • Tray menu actions worked, including Quit Mouser, which exited the process.
  • Cmd-Q hid to tray and did not terminate Mouser.
  • Apple menu logout/restart/shutdown did not leave Mouser blocking session termination.
  • Accessibility revoked/granted behavior was checked: revoked permission prompts and does not start remapping; granted permission starts remapping normally.

@hieshima hieshima merged commit 44880fb into TomBadash:master May 15, 2026
1 check passed
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.

2 participants