Copyosity is a menu-bar (Accessory) app. The tray menu blinks (opens and instantly
dismisses) when AppKit activation, hidden window z-order, and NSStatusItem menu popup race
each other. This document is the single source of truth for the fix that works — read it
before changing tray startup, tray_macos.rs, window levels, or activation policy.
Smoke gate: make verify-tray on a real Mac (Accessibility for System Events). Exit code
2 = automation unavailable (inconclusive), not a pass.
Startup log (must match): [tray] startup: hidden main + deferred tray popup ready
Three separate macOS/Tauri issues stack on top of each other:
-
Tauri default menubar —
.enable_macos_default_menu(true)(the default) attaches a full Apple/File/Edit menubar to the hidden webview. That menubar fightsNSStatusItempopups and causes unpredictable blink (sometimes 1st click, sometimes 2nd). -
tray-iconperformClickbug — In crates.iotray-icon0.24.1, left-click usesbutton.performClick(None)instead ofpopUpStatusItemMenu. After the first open, repeat clicks can blink. We patch to tray-icon#318 (8cd6dce). Patch alone is not enough for Accessory apps — see (3). -
Accessory activation race — Menu-bar-only apps use
ActivationPolicy::Accessory. When the user clicks the status item, AppKit activates the app and tries to show the menu in the same event cycle. The menu opens and is dismissed immediately. The fix is a deferred popup on the next run-loop turn (classic LSUIElement workaround), plus one-time warmup activation. -
Hidden window z-order — The clipboard overlay
mainwebview exists at startup (Handy pattern;tauri.conf.jsonhas"windows": []). It must stay atHIDDEN_AUXILIARY_LEVEL(3) — below status-bar menu popups. Converting to NSPanel or raising to level 24 at startup regresses the first tray click. NSPanel conversion is lazy (first overlay show).
Critical insight from debugging: partial fixes alternate which click breaks:
| Approach tried | 1st click | 2nd+ click |
|---|---|---|
Plain show_menu_on_left_click(true) + patch |
Often OK | Blinks (performClick) |
| Hidden main, no deferred popup | Blinks | Often OK |
| Tray before main, defer main to async | App won't start / no window | — |
| Full scheme below | OK | OK |
Only the combined scheme below passed 5 consecutive clicks in tauri dev (user-confirmed).
Apply all of these together. Removing or “simplifying” any one item tends to break a different click.
builder.enable_macos_default_menu(false) // lib.rs, before .setup()If removed: Apple/File/Edit appear; tray menu blinks unpredictably.
app.set_activation_policy(ActivationPolicy::Accessory); // first in TRAY STARTUPIf moved after tray or removed: Dock flash or activation races at startup.
3. Hidden main before tray (sync, main thread)
ensure_main_overlay_window(app.handle())?; // before TrayIconBuilder- Created in
setupon the main thread (not async defer — async breaksmake dev). visible(false), level 3 viamacos_window::apply_hidden_auxiliary_webview.tauri.conf.json:"windows": []— window is programmatic only.
If tray is created before main: devUrl/WebKit startup can still race; order above is verified.
If main creation is deferred to async: window creation fails or app exits; make dev breaks.
.show_menu_on_left_click(false)
.on_tray_icon_event(|tray, event| { /* Left + Down */ })On left mouse Down:
tray_macos::set_tray_highlight(tray, true)— immediate pressed look.tray_macos::schedule_tray_menu_popup(tray)— 1 ms tokio sleep, thenrun_on_main_thread:highlight(true)→inner.show_menu()→highlight(false).
Paste target on tray Down (lightweight only): use
clipboard_macos::note_tray_menu_paste_target() — stores the frontmost non-self PID in
LAST_NON_SELF_FRONTMOST_PID for quick-menu fallback. Do not call remember_paste_target()
or remember_paste_target_for_pid() here: those walk Accessibility (AX focus, mouse position)
and run in the same event cycle as the deferred popup, which regresses the 1st click
(ccc9504 / post-merge App Store builds).
Full paste capture (remember_paste_target_for_pid) stays in quick_menu::show when the user
picks Snippets from the tray menu — that path runs after the menu is already open.
Implementation: tray_macos.rs,
clipboard_macos/mod.rs (note_tray_menu_paste_target).
If switched to show_menu_on_left_click(true): 2nd/3rd click blink returns.
If defer removed but show_menu_on_left_click(false) kept: 1st click blinks.
If highlight re-assert before show_menu() removed: menu works but icon looks unpressed.
If any AX / panel / activation work added to the Down handler: 1st click blinks (keep Down handler to highlight + schedule popup + PID note only).
tray_macos::warmup_app_for_status_item_menu(); // after tray builtCalls NSApplication::activateIgnoringOtherApps(true) once at startup.
If changed to activate() or activateIgnoringOtherApps(false): 1st click blinks (review
regression observed in practice).
[patch.crates-io]
tray-icon = { git = "...", rev = "8cd6dce9bae905069416e524a077a3a9d7a6b451" }If removed: performClick path returns; repeat clicks blink even with deferred popup.
ensure_main_overlay_panel runs on first overlay show, not in setup.
If main → NSPanel at startup: hidden panel competes with status item; 1st click blinks.
voice_overlay / command_palette are lazy (first hotkey). Pre-creating NSPanels after tray
disturbs z-order and regressed tray in review.
activation_macos.rs: Regular only for Settings;
maybe_restore_accessory when overlay hides if settings window still exists (is_some(),
not is_visible()).
If restore uses is_visible(): closing settings can leave app stuck in Regular.
Plain .show_menu_on_left_click(true) — no tray_macos handler.
11. Startup webview stays minimal (no extra AppKit on hidden main)
macos_window::apply_hidden_auxiliary_webview (called from ensure_main_overlay_window at startup)
may only set collection behavior, level 3, and hidesOnDeactivate. Do not call
apply_transparent_webview (WKWebView tree walk) or to_panel there — release/DMG transparency
fixes belong in lazy panel paths (ensure_main_overlay_panel, voice/palette first create).
If apply_transparent_webview runs at startup on hidden main: 1st click blink (touches
NSWindow during the tray click window).
Keep "windows": []. A static main entry (especially with "alwaysOnTop": true) creates a
second startup path that fights the programmatic hidden window and regressed tray after upstream
merges.
If main reappears in tauri.conf.json: unpredictable 1st/2nd click; remove it and rely on
ensure_main_overlay_window only.
When merging from upstream or App Store release branches, diff TRAY STARTUP in
lib.rs first. These upstream patterns fully break the fix and
have landed multiple times (cb8563c, 069689b era):
| Upstream “simplification” | Symptom |
|---|---|
app.get_webview_window("main").unwrap().to_panel() in setup |
1st click blink |
panel.set_level(24) (or PANEL_LEVEL_ACTIVE) at startup |
1st click blink |
Plain TrayIconBuilder::…build() without tray_macos defer |
2nd+ click blink |
No enable_macos_default_menu(false) |
Menubar / blink |
ensure_voice_overlay / ensure_command_palette in setup |
1st click blink |
remember_paste_target() on tray Down |
1st click blink |
After every merge that touches lib.rs setup or macos_window.rs, run make verify-tray or
manual 5 clicks in make dev before shipping.
| File | Role |
|---|---|
lib.rs |
TRAY STARTUP block, ensure_main_overlay_window, lazy panel |
tray_macos.rs |
Deferred popup, highlight, warmup |
activation_macos.rs |
Accessory ↔ Regular |
macos_window.rs |
HIDDEN_AUXILIARY_LEVEL (3) vs 24 |
clipboard_macos/mod.rs |
note_tray_menu_paste_target (tray Down); full capture in quick menu |
Cargo.toml |
tray-icon patch, NSStatusItem features |
tauri.conf.json |
"windows": [] |
verify-tray-startup.sh |
Debug binary, 3 clicks |
verify-tray-dev.sh |
tauri dev + Vite, 3 clicks |
| Change | What breaks |
|---|---|
Plain show_menu_on_left_click(true) on macOS |
2nd/3rd click blink |
Remove deferred popup (tray_macos) |
1st click blink |
warmup: activate() / activateIgnoringOtherApps(false) |
1st click blink |
| Remove highlight re-assert in deferred popup | Icon not highlighted while menu open |
Defer ensure_main_overlay_window to async |
make dev / window creation |
| Re-pin window level in a startup loop | 1st click blink (touches window during click window) |
| Tray-only startup (no hidden main) | 2nd click blink |
Remove enable_macos_default_menu(false) |
Menubar pollution / blink |
Pre-create voice/palette NSPanels in setup |
Tray z-order / 1st click |
| Main → NSPanel at startup | 1st click blink |
Remove tray-icon patch |
Repeat-click performClick bug |
remember_paste_target() on tray Down (AX in click handler) |
1st click blink |
remember_paste_target_for_pid() on tray Down |
1st click blink (same AX race) |
apply_transparent_webview in apply_hidden_auxiliary_webview (startup) |
1st click blink |
Static main window in tauri.conf.json (alwaysOnTop, etc.) |
Z-order / activation races |
Revert TRAY STARTUP to pre-0bfce42 (to_panel in setup) |
1st click blink |
set_level(24) on hidden main or hidden panel after hide |
1st click blink |
dismiss_main_panel_if_visible / hide_command_palette on tray Down |
Menu dismiss race / blink |
activate() / activateIgnoringOtherApps in tray Down handler |
1st click blink |
| Merge upstream without re-reading this doc | Often reintroduces rows above |
Before merging tray/overlay/activation changes:
- Read this doc end-to-end.
make fix-backend && make check-backendmake verify-trayon a GUI Mac (or at minimumverify-tray-startup+ manual 5 clicks inmake dev). The smoke script checks Accessory activation-policy at startup and runs 5 clicks (3 tight at 0.1 s, 2 slower at 0.3 s) to catch both load-induced and repeat-click regressions.- Confirm log:
hidden main + deferred tray popup ready. - Do not “simplify” by dropping
tray_macosor switching to plain tray — that was tried multiple times and always regressed one click or the other. - Tray Down handler: highlight + deferred popup +
note_tray_menu_paste_targetonly — no AX, no panel hide/show, no activation calls. - After upstream merge: grep
lib.rsforto_panelinsetup,remember_paste_targetin tray handler, and"windows": [intauri.conf.json. ensure_main_overlay_panelidempotency is guaranteed by theAtomicBoolearly-return and enforced at runtime viadebug_assert!(MainThreadMarker::new().is_some()). A unit test is impractical without a full Tauri runtime — the code comment is the authoritative doc.