Skip to content

fix(lifecycle): hide main window to tray on close, Linux#451

Open
lizthegrey wants to merge 2 commits intoaaddrick:mainfrom
lizthegrey:lizf.fix-448-close-to-tray
Open

fix(lifecycle): hide main window to tray on close, Linux#451
lizthegrey wants to merge 2 commits intoaaddrick:mainfrom
lizthegrey:lizf.fix-448-close-to-tray

Conversation

@lizthegrey
Copy link
Copy Markdown
Contributor

@lizthegrey lizthegrey commented Apr 20, 2026

Summary

  • On Linux, intercept BrowserWindow.close on main windows and preventDefault + hide so the app survives a stray click on X (or a sign-out flow that closes mainWindow). Popups (Quick Entry, About) already dismiss via hide() and never see close events, so they're unaffected.
  • Arm app._quittingIntentionally in an app.on('before-quit') listener so the close handler still lets windows actually close when the user picks a real quit path — Ctrl+Q global shortcut (registered in this same file), tray Quit, cmd+Q, SIGTERM, or any other app.quit().
  • Gated by CLOSE_TO_TRAY (const at the top of the file, logged on startup). Default is on; CLAUDE_QUIT_ON_CLOSE=1 restores Electron-default behaviour for users who prefer X to quit.

Motivation

The existing tray patches keep the icon alive while the app runs, but Electron's default window-all-closed → app.quit() means the app dies the moment the last window closes. In-app schedulers (notably the /schedule skill) are in-process setTimeouts, so a missed overnight window = silently missed cron firings the next morning. My own ~/.config/Claude/logs/main.log caught this: clean beforeQuit at 15:21 PDT one afternoon, no restart until I noticed ~21 hours later, and an 8am scheduled task that simply never ran.

Diagnosis and design are in #448.

Interaction with existing patches

  • Ctrl+Q global shortcut (already in this file, Fixes: #321) — unchanged; app.quit()before-quit arms the flag → windows close normally.
  • Tray icon (scripts/patches/tray.sh) — its Quit menu item calls app.quit(), same flow as Ctrl+Q.
  • Popup detectionif (!popup) already gates the main-window-only code path; the close listener lives there, so Quick Entry / About are untouched.
  • Orphaned processes after closing the app — no clean shutdown or quit option #321 / CTRL + C doesn't close the app and process #424 (orphaned processes, Ctrl+C doesn't close) — different problem, different fix; this PR is about keeping the app alive deliberately rather than cleaning it up.

Fixes

#448

Test plan

  • node --check scripts/frame-fix-wrapper.js clean.
  • ./build.sh --build deb --clean no.
  • Launch, close main window via X → window disappears, tray icon remains, pgrep -f 'app\\.asar' still shows a live non-helper Electron PID.
  • Tray → Show App → window returns with preserved state.
  • Tray → Quit → beforeQuit fires in main.log, all Electron / cowork-vm-service PIDs exit.
  • Ctrl+Q while window is hidden but mini question bar is visible → app fully quits.
  • Ctrl+Q while window is visible → app fully quits.
  • Schedule a near-future /schedule task, close main window, wait → task fires on time.
  • Launch with CLAUDE_QUIT_ON_CLOSE=1, close window → app exits (env escape hatch works).
  • Quick Entry (Ctrl+Alt+Space) still dismisses and re-opens correctly; no hidden-window accumulation in BrowserWindow.getAllWindows().
  • win.webContents.close() / signout flow (if exercised via the UI) doesn't leave the app in a weird half-state.

Known trade-offs worth calling out in review

  1. Default on vs off — I made this opt-out (default on) because the Electron default is actively harmful for this app (in-process schedulers, tray is otherwise useless, missed crons). If you'd rather ship opt-in first and flip the default later, say the word and I'll reverse the gating.
  2. Programmatic win.close() — if the app itself calls mainWindow.close() for a non-quit reason (e.g., a hypothetical reset flow), our handler will hide instead of close. I don't think the current app does this outside of the real quit path (all the upstream call sites I've seen go through app.quit()), but worth watching for during test.
  3. Autostart interactionRun on startup setting not saved #128 (separate PR fix(autostart): route openAtLogin through XDG Autostart on Linux #450) lands the ~/.config/autostart/ integration. With both merged, a user can enable autostart and have the app survive stray closes, which is the combination needed for reliable overnight /schedule runs. Neither PR depends on the other.

Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
75% AI / 25% Human
Claude: pulled log evidence from ~/.config/Claude/logs, worked out the close/quit event ordering, drafted the wrapper patch and commit/PR copy, ran node --check.
Human: reported the missed-cron symptom that surfaced the bug, made the opt-out-vs-opt-in default call, and will run the manual test plan / own the merge decision.

Electron's default window-all-closed handler quits the app on
Linux. The existing tray icon and Ctrl+Q patches keep the app
reachable while a window is alive, but as soon as the last
window is closed (stray click on X, or a sign-out flow that
closes mainWindow) the app exits and the tray goes with it —
taking any in-app schedulers / MCP servers / cron tasks
(/schedule skill) down silently until the user re-launches.

Intercept BrowserWindow.close on main windows (not popups;
Quick Entry and About already dismiss via hide(), never emit
close) and preventDefault + hide unless app is in a real quit
path. The quit path is detected via before-quit: Ctrl+Q, tray
Quit, cmd+Q, SIGTERM and app.quit() from anywhere all emit
before-quit, which arms app._quittingIntentionally so the
close handler lets the window actually close.

Gated by CLOSE_TO_TRAY, default on. Set CLAUDE_QUIT_ON_CLOSE=1
to restore the Electron-default behaviour.

Fixes aaddrick#448

Co-Authored-By: Claude <claude@anthropic.com>
@lizthegrey lizthegrey requested a review from aaddrick as a code owner April 20, 2026 19:33
@aaddrick
Copy link
Copy Markdown
Owner

Hey Liz — one flag on the test plan after 4e2b9d7 landed.

The "Ctrl+Q while window is hidden → app fully quits" checkbox is probably a false pass now. Ctrl+Q is handled through webContents.before-input-event, which only fires when the webContents has keyboard focus. A hidden window can't hold focus, so the keybind should miss entirely from that state.

Could you re-run that one with the main window genuinely hidden and unfocused (not just minimized or behind another window) and update the test plan with what you actually observe? If it doesn't quit from there it's not a blocker — tray → Quit still works — but worth documenting the real behavior rather than carrying an inaccurate pass.


Written by Claude Opus 4.7 (1M context) via Claude Code

@lizthegrey
Copy link
Copy Markdown
Contributor Author

Could you re-run that one with the main window genuinely hidden and unfocused (not just minimized or behind another window) and update the test plan with what you actually observe? If it doesn't quit from there it's not a blocker — tray → Quit still works — but worth documenting the real behavior rather than carrying an inaccurate pass.

Ah yep my bad, so I'm indeed unable to close the main window and get Claude application to be focused to then control Q it. HOWEVER, I am able to double click the Claude application icon to get it to show the "How can claude help?" mini pop-up" and control-q works to close from there indeed.

@lizthegrey
Copy link
Copy Markdown
Contributor Author

Ah I see the source of my and Claude's confusion. on OS X the claude app remains command tabbable even with no windows open, which is why we thought that was something we had to test

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