From 432080e8087d74a91fe7f037c052092477cac874 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 5 Apr 2026 21:50:18 -0700 Subject: [PATCH 1/5] docs(desktop): add tray lifecycle research and migration plan Add architecture research, current-vs-target analysis, and short-term migration plan for macOS tray-resident lifecycle behavior. --- apps/desktop/HOST_SERVICE_BOUNDARIES.md | 13 +- apps/desktop/HOST_SERVICE_LIFECYCLE.md | 290 +++++++++--- ...0260405-macos-tray-short-term-migration.md | 429 ++++++++++++++++++ .../20260405-quit-tray-current-vs-target.md | 229 ++++++++++ .../20260405-tray-architecture-research.md | 306 +++++++++++++ .../lib/electron-app/factories/app/setup.ts | 2 +- .../src/lib/trpc/routers/settings/index.ts | 6 +- apps/desktop/src/main/index.ts | 110 +++-- apps/desktop/src/main/lib/auto-updater.ts | 7 +- apps/desktop/src/main/lib/lifecycle.ts | 60 +++ apps/desktop/src/main/lib/tray/index.ts | 13 +- apps/desktop/src/main/windows/main.ts | 66 ++- 12 files changed, 1377 insertions(+), 154 deletions(-) create mode 100644 apps/desktop/plans/20260405-macos-tray-short-term-migration.md create mode 100644 apps/desktop/plans/20260405-quit-tray-current-vs-target.md create mode 100644 apps/desktop/plans/20260405-tray-architecture-research.md create mode 100644 apps/desktop/src/main/lib/lifecycle.ts diff --git a/apps/desktop/HOST_SERVICE_BOUNDARIES.md b/apps/desktop/HOST_SERVICE_BOUNDARIES.md index 21340029654..43fe83e2820 100644 --- a/apps/desktop/HOST_SERVICE_BOUNDARIES.md +++ b/apps/desktop/HOST_SERVICE_BOUNDARIES.md @@ -230,12 +230,19 @@ From coordinator (in-process): ### Actions ``` -Restart → coordinator.restart(organizationId, config) +Restart → coordinator.restart(organizationId) Stop → coordinator.stop(organizationId) -Quit (keep services) → coordinator.releaseAll() + app.exit() -Quit (stop services) → coordinator.stopAll() + app.exit() +Quit (keep services) → requestQuit("release") → handled in `before-quit` +Quit (stop services) → requestQuit("stop") → handled in `before-quit` ``` +The tray does not directly call `releaseAll()` or `stopAll()`. Current quit semantics live in +`apps/desktop/src/main/index.ts` and are platform/state dependent: + +- macOS + active services + `null`/`"release"` quit mode → destroy windows, keep tray/app alive +- final `"release"` exit path → `releaseAll()` + `disposeTray()` + `app.exit(0)` +- final `"stop"` exit path → `stopAll()` + `disposeTray()` + `app.exit(0)` + ### Menu structure ``` diff --git a/apps/desktop/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/HOST_SERVICE_LIFECYCLE.md index 3daf0d05dc4..c57335192a0 100644 --- a/apps/desktop/HOST_SERVICE_LIFECYCLE.md +++ b/apps/desktop/HOST_SERVICE_LIFECYCLE.md @@ -1,75 +1,251 @@ # Host Service Lifecycle -## Architecture +This document describes the current v2 host-service quit, tray, and adoption behavior as implemented in the Electron desktop app today. -Electron main owns app lifecycle, tray, and host-service management. Host-services run as child processes that can outlive the app via manifest-based adoption. +## Scope + +- v2 terminals and v2 workspace runtime go through host-service child processes. +- v1 terminals still use the separate `src/main/terminal-host/` daemon and do not follow this quit/tray path. +- The tray is macOS-only. + +## Main pieces ``` -┌─────────────────────────────────────────────────────┐ -│ Electron Main Process │ -│ │ -│ ┌──────────┐ ┌──────────────────────┐ ┌───────┐ │ -│ │ Tray │ │ HostServiceManager │ │Windows│ │ -│ │ (macOS) │ │ │ │ │ │ -│ │ │◄─┤ status events │ │ hide/ │ │ -│ │ restart │ │ start/stop/adopt │ │ show │ │ -│ │ stop │ │ per org │ │ │ │ -│ │ quit ────┼──┼──► requestQuit(mode) │ │ │ │ -│ └──────────┘ └──────┬───────────────┘ └───────┘ │ -└───────────────────────┼─────────────────────────────┘ - │ IPC + stdio - ┌─────────────┼─────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌────────────┐ ┌────────────┐ ┌────────────┐ - │host-service│ │host-service│ │host-service│ - │ (org A) │ │ (org B) │ │ (org C) │ - │ │ │ │ │ │ - │ HTTP/tRPC │ │ HTTP/tRPC │ │ HTTP/tRPC │ - │ port:rand │ │ port:rand │ │ port:rand │ - │ │ │ │ │ │ - │ writes │ │ writes │ │ writes │ - │ manifest │ │ manifest │ │ manifest │ - └────────────┘ └────────────┘ └────────────┘ - │ │ │ - ▼ ▼ ▼ - ~/.superset/host/{orgId}/manifest.json +Electron main +├── `src/main/index.ts` +│ ├── owns `pendingQuitMode` +│ ├── handles `before-quit` +│ ├── discovers/adopts existing host-services on startup +│ └── initializes the tray +├── `src/main/lib/tray/index.ts` +│ ├── renders tray menu from HostServiceManager state +│ └── calls `requestQuit("release" | "stop")` +├── `src/main/lib/host-service-manager.ts` +│ ├── spawn / adopt / restart / stop / release +│ └── emits `status-changed` +└── `src/main/windows/main.ts` + └── macOS window close hides the window instead of destroying it ``` -### Quit modes +## Startup order -All quit paths use a single `QuitMode` (`"release" | "stop"`): +At app startup, the current sequence is: -- **release** — detach from services, they keep running for re-adoption on next launch -- **stop** — SIGTERM all services, then exit -- **implicit** (Cmd+Q with active services on macOS) — hide windows to tray +1. `app.whenReady()` +2. `getHostServiceManager().discoverAndAdoptAll()` +3. `makeAppSetup(() => MainWindow())` +4. `initTray()` -### Manifest adoption +That ordering matters: adopted services are loaded before tray init so the tray menu reflects already-running services immediately. -Each host-service child writes `~/.superset/host/{orgId}/manifest.json` on startup (pid, endpoint, authToken, version). It's a pidfile extended with connection info. +## Service survival model -- **Release quit** — children keep running, manifests stay on disk -- **Next launch** — `discoverAndAdoptAll()` scans manifests, health-checks each pid/endpoint, reconnects if healthy, removes and respawns if not -- **Stop quit** — SIGTERM children, they remove their own manifests on shutdown +Each host-service writes a manifest under `~/.superset/host/{orgId}/manifest.json` containing its pid, endpoint, auth token, version, and protocol version. -``` -App Launch App Quit (release) Next Launch -───────── ────────────────── ─────────── -spawn child ──► child writes parent detaches scan manifests - manifest.json manifests stay on disk health-check pid/endpoint - {pid, endpoint, child keeps running ├─ healthy → reconnect - authToken, ...} └─ dead/bad → remove, respawn -``` +- Release path: the desktop detaches from the child and leaves the manifest on disk. +- Next launch: `discoverAndAdoptAll()` scans manifests, checks pid liveness and `/trpc/health.check`, then adopts healthy services. +- Stop path: the desktop sends `SIGTERM`; the service is expected to remove its manifest during shutdown. +- Adopted services are polled for liveness every 5 seconds. If an adopted pid dies, the manager marks it `degraded` and schedules a restart. + +## Window lifecycle on macOS + +There are two distinct macOS "stay alive" paths: + +### 1. Window close (`Cmd+Shift+Q` in this app's menu, red close button) + +`src/main/windows/main.ts` intercepts `window.close` and: + +- saves window bounds +- calls `event.preventDefault()` +- hides the window + +This keeps the existing window object, app process, tray, and services alive. `app.on("activate")` or "Open Superset" in the tray re-shows the hidden window. + +### 2. App quit (`Cmd+Q`, app menu Quit, updater quit, tray quit) + +`src/main/index.ts` handles this in `before-quit`. This path can destroy windows, release services, stop services, or exit immediately depending on state. + +## Quit entry points + +### Standard app quit + +The normal app menu uses Electron's `role: "quit"`, so it calls `app.quit()` with no explicit quit mode. + +- `pendingQuitMode === null` +- confirm-on-quit may run +- macOS special-case can keep the tray alive if services are active + +### Tray quit + +The tray menu calls: + +- `requestQuit("release")` for "Quit (Keep Services Running)" +- `requestQuit("stop")` for "Quit & Stop Services" + +This sets `pendingQuitMode` before calling `app.quit()`. + +### Updater quit + +`installUpdate()` calls `prepareQuit("release")` and then `autoUpdater.quitAndInstall(false, true)`. + +This means updater installs currently reuse the same `"release"` `before-quit` path as tray release-quit. + +### App restart + +The settings router uses: + +- `app.relaunch()` +- `exitImmediately()` + +`exitImmediately()` calls `app.exit(0)` and bypasses `before-quit`, so it does not run `releaseAll()`, `stopAll()`, or `disposeTray()`. + +## Current `before-quit` flow + +`src/main/index.ts` is the single place that decides what app quit means. + +### Step 1: consume pending mode + +`before-quit` reads `pendingQuitMode` and immediately clears it so cancelled quits do not leave stale mode behind. + +Possible values: + +- `null` +- `"release"` +- `"stop"` + +### Step 2: macOS special-case when services are active + +If all of the following are true: + +- platform is macOS +- quit mode is `null` or `"release"` +- `manager.hasActiveInstances()` is true + +then the app does **not** exit. Instead it: + +- calls `event.preventDefault()` +- destroys every `BrowserWindow` +- returns without disposing the tray + +Result: + +- app process stays alive +- tray stays alive +- host-services stay running +- there are no open windows left + +This is different from the macOS window-close hide path: + +- window close hides the window +- app quit destroys windows + +### Step 3: quit confirmation + +If quit mode is still `null`, the app is not in development, and the `confirmOnQuit` setting is enabled, a blocking confirm dialog is shown. + +If the user cancels: + +- `before-quit` returns +- the app remains running +- `pendingQuitMode` has already been cleared + +Explicit tray quit modes skip this dialog. + +### Step 4: final exit path + +If the quit continues: + +- set `isQuitting = true` +- `"stop"` -> `manager.stopAll()` +- `null` or `"release"` -> `manager.releaseAll()` +- `disposeTray()` +- `app.exit(0)` + +Important detail: `app.exit(0)` bypasses window close handlers, so per-window cleanup in `src/main/windows/main.ts` does not run during this path. + +## Non-macOS behavior + +- The tray is never initialized on Windows or Linux. +- `window-all-closed` calls `app.quit()`. +- `before-quit` still decides between release and stop. +- Release quit exits the app and leaves services running for next-launch adoption. + +## Tray behavior + +The tray is initialized only on macOS and only if the tray icon loads successfully. + +### Menu contents + +The tray currently shows: + +- Host Service (`N`) +- Open Superset +- Settings +- Check for Updates +- Quit actions + +If there are active services: + +- "Quit (Keep Services Running)" +- "Quit & Stop Services" + +Otherwise: + +- "Quit" + +Each active org section can show: + +- organization name +- status +- version +- uptime +- restart count +- update-required / update-available hints +- Restart action +- Stop action + +### Refresh behavior + +The tray menu refreshes from two sources: + +- immediately on `HostServiceManager` `status-changed` +- every 5 seconds via polling as a fallback + +## Current behavior that is easy to miss + +### 1. "Release quit" does not always exit on macOS + +On macOS, `"release"` plus active services does not go through `releaseAll() + app.exit(0)`. It hits the earlier `before-quit` branch that destroys windows and leaves the tray + app process alive. + +### 2. Implicit quit and explicit release quit share the same macOS branch + +Both of these hit the same branch when services are active: + +- standard quit with `pendingQuitMode === null` +- explicit tray/update `"release"` + +### 3. Tray release-quit and updater quit are coupled today + +Because the updater uses `prepareQuit("release")`, an update install currently takes the same macOS branch as tray release-quit when services are active. + +### 4. Restart bypasses the central quit logic + +`restartApp` does not flow through `before-quit`. It exits immediately after scheduling a relaunch. -### v1 vs v2 terminal paths +### 5. Window close and app quit are intentionally not the same thing -v1 terminals run on a separate **terminal-host daemon** (`src/main/terminal-host/`) — a persistent background process that owns PTYs over a Unix domain socket. It has its own survival and reconnection model independent of host-service. +On macOS: -v2 terminals run through **host-service** child processes. The quit/adopt/tray lifecycle described here only applies to host-service instances. +- close window -> hide current window +- quit app with active services -> destroy windows, keep tray alive +- final exit path -> dispose tray and `app.exit(0)` -### Design decisions +## Related files -- **No supervisor process.** Electron main owns everything. Simpler while v1 and v2 coexist. -- **No tray on Windows/Linux.** Services still survive quit and are re-adopted, but there's no persistent UI to manage them. -- **Tray calls `requestQuit(mode)`.** One function, one codepath — no setter chains or flag mutation. -- **Manifest handling is single-sourced.** Both parent and child use `host-service-manifest.ts`. Files are written with 0o600 permissions. +- `apps/desktop/src/main/index.ts` +- `apps/desktop/src/main/lib/tray/index.ts` +- `apps/desktop/src/main/lib/host-service-manager.ts` +- `apps/desktop/src/main/windows/main.ts` +- `apps/desktop/src/lib/electron-app/factories/app/setup.ts` +- `apps/desktop/src/main/lib/auto-updater.ts` +- `apps/desktop/src/lib/trpc/routers/settings/index.ts` diff --git a/apps/desktop/plans/20260405-macos-tray-short-term-migration.md b/apps/desktop/plans/20260405-macos-tray-short-term-migration.md new file mode 100644 index 00000000000..1546038ef0e --- /dev/null +++ b/apps/desktop/plans/20260405-macos-tray-short-term-migration.md @@ -0,0 +1,429 @@ +# Short-Term Migration: macOS Tray-Resident Lifecycle + +## Objective + +In the short term, we want a consistent macOS lifecycle where: + +- closing the last window +- quitting from the Dock +- pressing `Cmd+Q` +- other normal app quit requests + +all tear down the app UI and leave Superset resident in the tray. + +At the same time, we must preserve a separate path for: + +- explicit full exit +- explicit full exit + stop services +- install update +- restart app + +because those paths need the process to actually terminate. + +## Why this needs a migration + +Today, Superset already has some tray-resident behavior, but it is inconsistent and it breaks update install. + +### Current behavior summary + +1. **Window close hides the existing window** + - `apps/desktop/src/main/windows/main.ts` + - On macOS, window close calls `window.hide()`, so the current `BrowserWindow` is retained. + +2. **Implicit quit only backgrounds to tray when active host-services exist** + - `apps/desktop/src/main/index.ts` + - In `before-quit`, macOS returns early and keeps tray alive only when: + - quit mode is `null` or `"release"` + - `manager.hasActiveInstances()` is true + +3. **Otherwise quit fully exits** + - same file + - The final branch calls: + - `releaseAll()` or `stopAll()` + - `disposeTray()` + - `app.exit(0)` + +4. **Updater install is currently broken by the quit interception** + - `apps/desktop/src/main/lib/auto-updater.ts` + - `installUpdate()` does: + - `prepareQuit("release")` + - `autoUpdater.quitAndInstall(false, true)` + - But the macOS `before-quit` branch treats `"release"` as "destroy windows and keep tray alive" when services are active, so the app never fully quits for install. + +## Short-term target + +This migration stays within a **single Electron app/runtime**. It does **not** attempt the full Docker-style split-process architecture yet. + +The short-term target is: + +- introduce a long-lived tray owner inside the current Electron runtime +- separate tray lifetime from window lifetime +- destroy windows on background transitions instead of hiding them +- recreate windows on demand from tray / activate +- keep full-exit/update/restart as explicit separate flows + +## Non-goals + +- No separate native tray process yet +- No full Docker-style split runtime yet +- No host-service architecture rewrite +- No Windows/Linux tray behavior redesign in this phase + +## Product model after migration + +After this migration, we should have two categories of lifecycle action. + +### 1. Background-to-tray actions + +These should destroy windows and keep the tray alive: + +- close last window +- Dock Quit +- `Cmd+Q` +- app menu Quit + +### 2. Full-exit actions + +These should terminate the process: + +- explicit tray "Quit Superset" +- explicit tray "Quit & Stop Services" +- update install +- restart app + +This is the key separation missing today. + +## Architectural changes + +## 1. Introduce a long-lived tray owner + +Create a dedicated tray lifecycle module that is initialized once after `app.whenReady()` and remains alive until a true full exit. + +Responsibilities: + +- create and own the tray +- rebuild tray menu +- handle tray-driven actions +- expose "open/create window" action +- survive window teardown +- only dispose on true full exit + +This is different from the current setup where tray init/dispose is implicitly tied to the central quit path. + +## 2. Separate tray lifetime from window lifetime + +Introduce a window lifecycle owner separate from the tray owner. + +Responsibilities: + +- create a fresh main window +- destroy all windows +- report whether any UI windows exist +- recreate UI on tray click / activate + +Important behavioral change: + +- backgrounding to tray should **destroy** windows +- it should **not** hide and retain the existing `BrowserWindow` + +## 3. Introduce explicit lifecycle intents + +The short-term migration should stop overloading `"release"` to mean both: + +- "background to tray" +- "release services and fully exit" + +We need explicit intents, for example: + +- `background_to_tray` +- `exit_release` +- `exit_stop` +- `install_update` +- `restart` + +The exact enum names are flexible, but the separation is required. + +## Proposed control plane + +Add a central lifecycle coordinator that translates intents into actions. + +### Background path + +For `background_to_tray`: + +- prevent app quit +- destroy all windows +- keep tray alive +- keep process alive +- do not call `disposeTray()` +- do not call `app.exit(0)` + +### Full exit path + +For `exit_release`: + +- release services +- dispose tray +- quit process + +For `exit_stop`: + +- stop services +- dispose tray +- quit process + +For `install_update`: + +- release services if needed +- dispose tray +- allow updater-managed shutdown/install to proceed +- do **not** convert to background-to-tray + +For `restart`: + +- relaunch app +- dispose tray +- fully exit + +## Updater-specific migration + +This needs to be treated as first-class scope, because the current update path is already broken. + +## Current updater bug + +Current code: + +- `installUpdate()` sets `prepareQuit("release")` +- then calls `autoUpdater.quitAndInstall(false, true)` + +Current macOS quit behavior: + +- `"release"` can be intercepted and turned into "destroy windows, keep tray alive" + +Result: + +- update install requests can end up backgrounding the app instead of quitting it +- the downloaded update remains ready but does not install + +## Official updater behavior we need to respect + +Electron's auto-updater docs say: + +- `quitAndInstall()` closes application windows first, then automatically calls `app.quit()` +- `before-quit-for-update` exists because `before-quit` does not run before windows close in the normal way during update shutdown + +References: + +- Electron autoUpdater docs: +- electron-updater docs: + +This means our current "generic `before-quit` intercept everything" approach is the wrong shape for updates. + +## Required updater changes + +### 1. Stop using `"release"` as the update-install signal + +Do not model update install as `"release"`. + +It needs its own explicit lifecycle intent, e.g. `install_update`. + +### 2. Skip background-to-tray logic for update install + +When update install is in progress: + +- do not hit the macOS background-to-tray branch +- do not leave tray resident +- do not prevent the process from actually terminating + +### 3. Let the updater own the final quit/install sequence + +For update install, the app should prepare state and cleanup, but it should not replace the updater's quit/install sequence with a generic `app.exit(0)` branch. + +Short-term rule: + +- updater path prepares for shutdown +- updater still owns `quitAndInstall()` + +### 4. Add dedicated update-shutdown cleanup + +We should add a dedicated update cleanup path, likely keyed off: + +- explicit lifecycle intent `install_update` +- and, if surfaced cleanly through the current updater package, `before-quit-for-update` + +That cleanup path should: + +- release host services if they are meant to survive update/relaunch +- dispose tray +- mark the app as truly quitting +- avoid background fallback behavior + +## Tray UX changes required + +If macOS `Cmd+Q` / Dock Quit now background to tray, then we still need an explicit way for the user to fully exit. + +Recommended tray actions after migration: + +- `Open Superset` +- `Quit Superset` +- `Quit & Stop Services` + +The current "Quit (Keep Services Running)" tray action becomes ambiguous once normal OS quit no longer exits. We should rename this to make the behavior explicit. + +Recommended meaning: + +- `Quit Superset` -> full exit, keep services running +- `Quit & Stop Services` -> full exit, stop services + +## Migration phases + +## Phase 1: Introduce lifecycle coordinator and separate owners + +Goal: + +- create the architectural seams without changing user-facing behavior yet + +Work: + +- add tray owner module +- add window owner module +- add lifecycle intent model +- route current tray actions through lifecycle coordinator + +Output: + +- tray lifecycle is no longer hard-coded directly into the generic quit branch + +## Phase 2: Convert macOS close and implicit quit into background-to-tray + +Goal: + +- make close / Dock Quit / `Cmd+Q` destroy windows and leave tray alive + +Work: + +- replace `window.hide()` on macOS close with background-to-tray teardown +- remove `hasActiveInstances()` gating from implicit quit-to-tray behavior +- make reopen create a fresh window rather than re-showing a retained hidden one + +Output: + +- one consistent macOS background behavior + +## Phase 3: Fix explicit full-exit paths + +Goal: + +- preserve true exit semantics where they are actually needed + +Work: + +- keep explicit tray full-exit action +- keep explicit tray full-exit-and-stop action +- route restart through full-exit lifecycle + +Output: + +- background and full-exit semantics are no longer mixed together + +## Phase 4: Fix updater install path + +Goal: + +- make install update always perform a real quit/install sequence + +Work: + +- replace updater's current `"release"` quit preparation with dedicated `install_update` intent +- skip background-to-tray logic during update install +- add dedicated update cleanup path +- ensure tray is disposed on update install +- ensure host-service release/cleanup is deliberate and not inherited accidentally from generic quit + +Output: + +- clicking `Install` from update UI actually installs the update + +## Phase 5: Verification and cleanup + +Goal: + +- lock behavior down with tests and a manual verification checklist + +Work: + +- unit/integration coverage around lifecycle coordinator +- updater install path verification +- tray reopen verification +- close/quit behavior verification with and without active services + +## Acceptance criteria + +The migration is done when all of the following are true on macOS: + +1. Closing the last window leaves no open windows and the tray remains alive. +2. `Cmd+Q` leaves no open windows and the tray remains alive. +3. Dock Quit leaves no open windows and the tray remains alive. +4. Reopening from tray creates a fresh window successfully. +5. This behavior does not depend on `hasActiveInstances()`. +6. There is still an explicit tray action that fully exits the app. +7. There is still an explicit tray action that fully exits and stops services. +8. Clicking update install fully quits and installs the update. +9. Update install does not get stuck in a tray-resident state. + +## Manual verification checklist + +### Background-to-tray checks + +- Launch app with no active services, close last window -> tray stays alive, no windows remain +- Launch app with active services, close last window -> same result +- Launch app with no active services, press `Cmd+Q` -> tray stays alive, no windows remain +- Launch app with active services, press `Cmd+Q` -> same result +- Use Dock Quit with and without active services -> same result +- Reopen from tray after each case -> fresh window appears + +### Full-exit checks + +- Use explicit tray full exit -> process terminates, tray disappears, services remain running if expected +- Use explicit tray full exit + stop -> process terminates, tray disappears, services stop + +### Update checks + +- Simulate update ready, click install -> app actually quits for install +- Verify update path does not leave the app sitting in tray-only state + +## Risks + +### 1. macOS convention risk + +Making `Cmd+Q` background to tray is non-standard for many macOS apps. This is intentional here, but it is still a product decision and should be treated as such. + +### 2. Regressions from switching hide -> destroy + +Destroying windows instead of hiding them changes: + +- window state retention behavior +- subscriptions/listeners tied to window instances +- assumptions in reopen flows + +That is why the window owner split is important. + +### 3. Updater lifecycle complexity + +Update install is not just another quit path. It has distinct semantics and Electron explicitly documents that its event sequence differs from normal quit. + +We should not keep treating it as a special case of `"release"`. + +## Recommended implementation order + +1. Introduce lifecycle intents and tray/window owners +2. Move implicit macOS close/quit to background-to-tray +3. Add explicit full-exit tray action naming +4. Fix updater install path with dedicated intent +5. Add tests and verification checklist + +## References + +- Electron autoUpdater docs: +- electron-builder auto update docs: +- electron-updater API docs: diff --git a/apps/desktop/plans/20260405-quit-tray-current-vs-target.md b/apps/desktop/plans/20260405-quit-tray-current-vs-target.md new file mode 100644 index 00000000000..6ff7b9f2d36 --- /dev/null +++ b/apps/desktop/plans/20260405-quit-tray-current-vs-target.md @@ -0,0 +1,229 @@ +# macOS Quit / Tray Lifecycle: Current Behavior vs Target + +## Goal + +Align macOS behavior so that: + +- closing the last window +- quitting from the Dock +- pressing `Cmd+Q` +- handling the normal quit path / quit signal + +all remove the app UI while keeping the tray alive. + +## Architectural direction + +The fix is not just "change a few quit branches." We want to separate tray lifetime from window lifetime. + +The target architecture is: + +- a long-lived tray/background module owns tray lifecycle +- window lifecycle becomes separate from tray lifecycle +- closing or quitting tears down app UI windows +- tray/background runtime stays alive and can recreate windows later + +In other words, the tray should not be treated as something owned by the current main-window lifecycle. + +### Practical constraint + +If "kill main" means "tear down the current app UI / window management flow", that is achievable with a long-lived tray/background module. + +If "kill main" means "terminate the Electron process that owns the tray", then the tray still cannot survive that. A true tray-survives-process-exit design would require a separate background entrypoint/process, not just a module split. + +For this note, the intended target is: + +- destroy the main window(s) / app UI +- keep a long-lived tray/background runtime resident +- keep the tray alive + +## Current behavior + +### 1. Closing the last window on macOS hides it, it does not destroy it + +In `apps/desktop/src/main/windows/main.ts`, the window `close` handler: + +- saves window state +- calls `event.preventDefault()` +- calls `window.hide()` + +Result: + +- the existing `BrowserWindow` stays alive in memory +- the app process stays alive +- the tray stays alive +- re-open is effectively a re-show of the same hidden window + +This is a hide-to-tray style behavior, not a destroy-window / background-only behavior. + +### 2. Standard app quit (`Cmd+Q`, Dock Quit, app menu Quit) goes through `before-quit` + +In `apps/desktop/src/main/index.ts`, standard quit calls `app.quit()`, which reaches the central `before-quit` handler. + +That handler currently behaves differently depending on state. + +#### Case A: macOS + active host-service instances + quit mode `null` or `"release"` + +The quit is prevented and Electron: + +- destroys all `BrowserWindow`s +- returns early +- leaves the tray alive +- leaves the app process alive +- leaves host-services alive + +This is the one path that already matches the desired shape fairly closely. + +#### Case B: no active instances, or a path that falls through the early return + +The app continues through the final exit path: + +- optional quit confirmation for implicit quit +- `releaseAll()` or `stopAll()` +- `disposeTray()` +- `app.exit(0)` + +Result: + +- app process exits +- tray dies + +So today, standard quit only becomes "background to tray" when active host-service instances exist. + +### 3. Tray quit uses explicit quit modes + +The tray menu currently calls: + +- `requestQuit("release")` +- `requestQuit("stop")` + +Those modes still route into the same `before-quit` logic above. + +So tray quit is not its own lifecycle model; it is just a different entry point into the central quit handler. + +### 4. Some exit paths bypass `before-quit` + +There are direct exit paths that currently do not align with a tray-resident target: + +- dev `SIGTERM` / `SIGINT` handlers call `app.exit(0)` directly +- app restart calls `app.relaunch()` then `exitImmediately()` + +Those paths terminate the Electron process instead of transitioning to tray. + +## Desired target behavior + +The desired macOS model is: + +- closing the last window destroys the window UI instead of hiding it +- `Cmd+Q` destroys the window UI instead of exiting the whole app +- Dock Quit destroys the window UI instead of exiting the whole app +- the tray remains alive after any of those actions +- this behavior should not depend on `hasActiveInstances()` + +Operationally, the target is: + +- no visible app windows +- no hidden retained main window +- long-lived tray/background runtime still alive +- tray still alive +- app can be reopened from tray / activate flow by creating a fresh window when needed + +### Structural implication + +To get there cleanly, the tray should move behind a dedicated long-lived module that: + +- owns tray creation and disposal +- survives window teardown +- handles re-open / create-window actions +- receives quit intents and decides whether they mean "destroy windows" or "full process exit" + +That is the layer that should remain alive when the last window closes or when macOS quit is converted into "background to tray." + +## Delta from current behavior + +### Delta 1: window close path is wrong today + +Current: + +- close last window -> `window.hide()` + +Target: + +- close last window -> destroy the window and leave only tray/background process + +### Delta 2: quit-to-tray is incorrectly gated on active services + +Current: + +- `Cmd+Q` / Dock Quit only stay alive if `manager.hasActiveInstances()` is true + +Target: + +- `Cmd+Q` / Dock Quit should background to tray regardless of host-service activity + +### Delta 3: normal quit still disposes the tray in common cases + +Current: + +- quit often falls through to `disposeTray()` + `app.exit(0)` + +Target: + +- normal macOS quit path should destroy windows but not dispose the long-lived tray/background module + +### Delta 4: current close path retains the existing window object + +Current: + +- the app keeps the existing hidden `BrowserWindow` + +Target: + +- the app should likely destroy window instances and recreate them on re-open + +This is an important semantic difference because "hidden existing window" and "no windows, tray-only app" are not the same lifecycle. + +### Delta 5: signal/direct-exit paths are not yet aligned + +Current: + +- some quit/exit paths call `app.exit(0)` directly + +Target: + +- any quit path that is supposed to behave like macOS quit-to-tray needs to route through the same tray/background lifecycle module instead of directly terminating Electron + +### Delta 6: tray ownership is coupled to the current main lifecycle + +Current: + +- tray init/dispose is directly wired into the existing app startup and quit flow +- tray lifetime is effectively tied to the current main lifecycle branches + +Target: + +- tray ownership should live in a separate long-lived module +- window teardown should not imply tray teardown +- full process exit should be an explicit, narrower path than "close or quit to tray" + +## Concrete summary + +Today there are effectively three macOS behaviors: + +1. window close -> hide existing window +2. quit with active services -> destroy windows, keep tray alive +3. quit without active services -> exit app and destroy tray + +The target is one consistent behavior: + +1. close or quit -> destroy windows, keep tray alive + +## Likely implementation surface + +The main files that define this behavior today are: + +- `apps/desktop/src/main/index.ts` +- `apps/desktop/src/main/windows/main.ts` +- `apps/desktop/src/lib/electron-app/factories/app/setup.ts` +- `apps/desktop/src/main/lib/tray/index.ts` + +If we implement the target, those files are where the lifecycle consolidation will happen. The main architectural change is to introduce a longer-lived tray/background owner and make window teardown route through it instead of treating tray disposal as part of the default quit path. diff --git a/apps/desktop/plans/20260405-tray-architecture-research.md b/apps/desktop/plans/20260405-tray-architecture-research.md new file mode 100644 index 00000000000..554da5d66de --- /dev/null +++ b/apps/desktop/plans/20260405-tray-architecture-research.md @@ -0,0 +1,306 @@ +# Tray Architecture Research: Electron Patterns vs Docker-Style Split Runtime + +## Question + +What pattern should we use if we want a tray that outlives the current Electron UI/main lifecycle, in a way that feels closer to Docker Desktop than to a typical "hide window to tray" Electron app? + +## Bottom line + +There are three distinct patterns in the wild: + +1. **Standard Electron tray app** + - One Electron main process owns tray, app lifecycle, and windows. + - Closing windows leaves the Electron app resident. + - Tray does **not** outlive Electron main. + +2. **Electron app with background worker/backend** + - Electron main still owns tray and windows. + - Long-lived work moves into a child Node/utility/backend process. + - Tray still does **not** outlive Electron main. + +3. **Docker-style split runtime** + - Background runtime and UI are decoupled. + - Tray belongs to the long-lived background runtime, not to the transient dashboard window flow. + - If we want the tray to outlive the current Electron UI/main lifecycle, this is the right family of architecture. + +The key implication is: + +- a separate **module** for tray is useful +- but a separate module alone is **not** enough to make the tray outlive the current Electron main lifecycle in a Docker-like way +- for that, we need a separate **long-lived runtime/process boundary**, not just a file split + +## What Electron itself supports + +### Tray is a main-process concern + +Electron's official tray guide creates the tray from `electron/main`, and its "minimizing to tray" pattern works by keeping the app alive when windows are closed. + +Sources: + +- Electron Tray guide: +- Electron Process Model: + +Relevant points from the docs: + +- Tray is created from the main process API surface. +- To keep tray alive when all windows are closed, Electron recommends handling `window-all-closed` without quitting. +- Electron has a single main process that controls application lifecycle and native APIs such as tray, dialogs, and menus. + +### Utility/background processes are supported, but they do not solve tray ownership + +Electron also supports child/utility processes for background work. + +Source: + +- Electron utility process API: + +That is useful for: + +- background services +- crash isolation +- expensive work + +But it does **not** move tray ownership out of the main-process lifecycle. It only helps split background computation away from UI. + +## Existing app patterns + +## Pattern A: Standard Electron tray-first app + +### Stretchly + +Stretchly is a cross-platform Electron app that explicitly describes itself as living in the tray and only showing windows when needed. + +Source: + +- Stretchly README: + +Relevant behavior: + +- "Stretchly itself lives in your tray" +- preferences are opened from tray +- the tray icon can even be disabled, which removes the graphical reopen path + +This is the classic Electron tray-first pattern: + +- keep Electron resident +- no requirement for a persistent main window +- spawn/show windows on demand + +This is a good reference for: + +- tray-owned app UX +- create-window-on-demand behavior + +It is **not** a reference for "tray outlives Electron main", because the tray still belongs to the one Electron app process. + +### Daily Electron Overlay demo + +Daily's Electron overlay demo also describes the app as living in the system tray. + +Source: + +- Daily electron-overlay README: + +This is the same family of design: + +- tray-first +- UI windows are secondary / transient +- Electron process remains alive + +Again: useful for tray-first UX, not for Docker-style runtime separation. + +## Pattern B: Electron app plus background backend process + +### `electron-with-server-example` + +James Long's example demonstrates an Electron app that forks a background Node process and communicates with it directly over a local socket. + +Source: + +- Example repo: + +Why this matters: + +- it is a clean example of separating durable backend work from Electron window lifecycle +- it shows a direct socket/named-pipe style IPC path instead of pushing everything through Electron IPC + +This is very relevant for Superset because it matches the shape of: + +- long-lived backend/runtime +- Electron shell as UI/controller + +But it still leaves tray ownership in Electron main. So it solves backend durability, not tray-survives-main. + +## Pattern C: Docker-style split runtime + +Docker Desktop is the best product reference for the UX we want, but its public docs do **not** expose a detailed internal Electron process diagram. So the following is partly direct evidence and partly inference from the product docs. + +Sources: + +- Explore Docker Desktop: +- Docker Desktop CLI: +- Docker Desktop settings: +- Docker Desktop install/uninstall docs: + - Windows install: + - Uninstall: + +### Direct evidence from Docker docs + +The docs show: + +- Docker Desktop has a tray/menu entry point ("The Docker menu") +- the Docker menu has a "Dashboard" action, which implies the tray/menu is not the same thing as the dashboard window +- Docker Desktop has a setting for "Open Docker Dashboard when Docker Desktop starts", which means startup and dashboard display are intentionally decoupled +- Docker Desktop exposes `docker desktop start|stop|restart|status` CLI commands +- Docker installs/runs background helpers and services such as `com.docker.service` on Windows and privileged helper tools like `com.docker.vmnetd` on macOS + +### Inference from those docs + +This strongly suggests a **split-runtime product model**: + +- long-lived desktop/background runtime +- optional/transient dashboard UI +- separate backend services/helpers + +That is the right conceptual target for us. + +What we should **not** infer without stronger evidence: + +- that Docker's tray is literally outside Electron +- that Docker uses a second Electron executable rather than a native helper +- exact process boundaries inside Docker Desktop + +But the docs are enough to support the larger architecture conclusion: + +- Docker is not using the basic "single Electron main owns everything forever" pattern as the product boundary +- the product boundary is "desktop runtime" first, "dashboard window" second + +## What this means for Superset + +## Option 1: Improve current Electron structure only + +We can: + +- create a dedicated tray lifecycle module +- make windows disposable and recreated on demand +- keep Electron resident with no windows + +This gets us to: + +- tray-first app +- no hidden retained main window +- better lifecycle separation than today + +This is a good incremental step. + +But it is still Pattern A: + +- tray survives window teardown +- tray does **not** survive Electron main exit + +## Option 2: Docker-style split runtime + +If the real requirement is: + +- tray/background survives the current UI/main lifecycle +- the dashboard window is just an attachable client + +then we need a real runtime split. + +### Recommended shape + +1. **Tray host runtime** + - long-lived + - owns tray/menu bar icon + - owns reopen/create-window actions + - receives quit/restart intents + - decides whether to destroy windows or fully exit + +2. **UI runtime** + - dashboard/workspace windows + - can be created/destroyed independently + - talks to tray host and backend over socket/named pipe IPC + +3. **Backend/service runtime** + - host services + - daemon/session/runtime management + - background work that should survive UI churn + +### Process model choices + +There are two realistic ways to do this: + +#### A. Small Electron tray host + separate UI Electron process + +Pros: + +- easiest if we want to reuse Electron for tray/menu APIs +- shared JS/TS stack +- simpler to evolve from current code + +Cons: + +- still more than one Electron runtime +- packaging/relaunch/update story becomes more complex + +#### B. Native tray host + Electron UI app + +Pros: + +- closest to a true Docker-style helper/desktop-runtime split +- smallest long-lived resident footprint +- strongest separation between tray/runtime and UI app + +Cons: + +- highest engineering cost +- native code, packaging, signing, and IPC complexity + +## Recommendation + +### Short term + +Do the structural cleanup inside the current app first: + +- introduce a tray lifecycle owner +- stop retaining a hidden main window +- make reopen create a fresh window +- route close / `Cmd+Q` / Dock Quit into one "destroy windows, keep tray runtime alive" path + +That gives us a cleaner tray-first architecture and removes the current lifecycle inconsistency. + +### Medium term + +If the product requirement remains "tray should outlive the current Electron main/UI lifecycle similar to Docker", plan for a process split: + +- tray host runtime +- UI runtime +- socket/named pipe IPC between them + +### Concrete recommendation for Superset + +Treat "separate tray module" as a **preparatory refactor**, not the final architecture. + +The final architecture should be described as: + +- **separate tray owner** +- **separate window owner** +- **eventually separate tray host runtime/process if we want true Docker-style behavior** + +## Decision guidance + +If the goal is only: + +- close/quit removes UI +- tray stays alive +- reopen creates a fresh window + +then a single resident Electron process is enough. + +If the goal is: + +- tray/runtime survives shutdown of the current Electron UI/main process +- the UI is attach/detach only +- closer to Docker Desktop's product boundary + +then we should design toward a separate tray host runtime/process. diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 05a1138209e..c8e4f26aab1 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -33,7 +33,7 @@ export async function makeAppSetup( if (!windows.length) { window = await createWindow(); } else { - // Show hidden windows (macOS hide-to-tray) or restore minimized ones + // Focus existing windows (e.g. minimized or behind other apps) for (window of windows.reverse()) { window.show(); window.focus(); diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 5876d80fd22..d382420f5d4 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -16,8 +16,7 @@ import { DEFAULT_TERMINAL_PRESET_AGENT_TYPES, } from "@superset/shared/agent-command"; import { TRPCError } from "@trpc/server"; -import { app } from "electron"; -import { exitImmediately } from "main/index"; +import { requestExit } from "main/lib/lifecycle"; import { hasCustomRingtone } from "main/lib/custom-ringtones"; import { localDb } from "main/lib/local-db"; import { @@ -695,8 +694,7 @@ export const createSettingsRouter = () => { }), restartApp: publicProcedure.mutation(() => { - app.relaunch(); - exitImmediately(); + requestExit("restart"); return { success: true }; }), diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 331341a2395..4e59b66dc1c 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -29,6 +29,12 @@ import { resolveDevWorkspaceName } from "./lib/dev-workspace-name"; import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; import { getHostServiceManager } from "./lib/host-service-manager"; +import { + consumeIntent, + isExiting, + isFullExitIntent, + markExiting, +} from "./lib/lifecycle"; import { localDb } from "./lib/local-db"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { initSentry } from "./lib/sentry"; @@ -150,30 +156,6 @@ app.on("open-url", async (event, url) => { } }); -export type QuitMode = "release" | "stop"; -let pendingQuitMode: QuitMode | null = null; -let isQuitting = false; - -/** Request the app to quit. - * - "release": keep services running (re-adoptable on next launch) - * - "stop": terminate all services before exit */ -export function requestQuit(mode: QuitMode): void { - pendingQuitMode = mode; - app.quit(); -} - -/** Set quit mode without triggering quit. - * Use when another API (e.g. autoUpdater.quitAndInstall) triggers quit internally. */ -export function prepareQuit(mode: QuitMode): void { - pendingQuitMode = mode; -} - -/** Exit the process immediately, bypassing before-quit. - * Services are left running for adoption on next launch. */ -export function exitImmediately(): void { - app.exit(0); -} - function getConfirmOnQuitSetting(): boolean { try { const row = localDb.select().from(settings).get(); @@ -184,20 +166,14 @@ function getConfirmOnQuitSetting(): boolean { } app.on("before-quit", async (event) => { - if (isQuitting) return; - - // Consume the quit mode so it doesn't persist across aborted quits - const quitMode = pendingQuitMode; - pendingQuitMode = null; + if (isExiting()) return; - const manager = getHostServiceManager(); + // Consume the intent so it doesn't persist across aborted quits + const intent = consumeIntent(); - // macOS: close windows & keep tray alive when services should stay running - if ( - PLATFORM.IS_MAC && - (quitMode === null || quitMode === "release") && - manager.hasActiveInstances() - ) { + // macOS: implicit quit (no intent) backgrounds to tray. + // Destroy all windows but keep the tray and process alive. + if (PLATFORM.IS_MAC && !isFullExitIntent(intent)) { event.preventDefault(); for (const win of BrowserWindow.getAllWindows()) { win.destroy(); @@ -205,45 +181,63 @@ app.on("before-quit", async (event) => { return; } - const isDev = process.env.NODE_ENV === "development"; - if (quitMode === null && !isDev && getConfirmOnQuitSetting()) { - event.preventDefault(); - - try { - const { response } = await dialog.showMessageBox({ - type: "question", - buttons: ["Quit", "Cancel"], - defaultId: 0, - cancelId: 1, - title: "Quit Superset", - message: "Are you sure you want to quit?", - }); - - if (response === 1) { - return; + // Windows/Linux implicit quit: optionally confirm before exiting + if (!isFullExitIntent(intent)) { + const isDev = process.env.NODE_ENV === "development"; + if (!isDev && getConfirmOnQuitSetting()) { + event.preventDefault(); + + try { + const { response } = await dialog.showMessageBox({ + type: "question", + buttons: ["Quit", "Cancel"], + defaultId: 0, + cancelId: 1, + title: "Quit Superset", + message: "Are you sure you want to quit?", + }); + + if (response === 1) { + return; + } + } catch (error) { + console.error("[main] Quit confirmation dialog failed:", error); } - } catch (error) { - console.error("[main] Quit confirmation dialog failed:", error); } } - isQuitting = true; - if (quitMode === "stop") { + // Full exit path + markExiting(); + const manager = getHostServiceManager(); + + if (intent === "exit_stop") { manager.stopAll(); } else { manager.releaseAll(); } + disposeTray(); + + if (intent === "install_update") { + // Let the updater own the final quit/install sequence. + // Don't call app.exit(0) — autoUpdater.quitAndInstall() handles it. + return; + } + + if (intent === "restart") { + app.relaunch(); + } + app.exit(0); }); process.on("uncaughtException", (error) => { - if (isQuitting) return; + if (isExiting()) return; console.error("[main] Uncaught exception:", error); }); process.on("unhandledRejection", (reason) => { - if (isQuitting) return; + if (isExiting()) return; console.error("[main] Unhandled rejection:", reason); }); diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index b985196bad9..5afa7c326b0 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { app, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import { env } from "main/env.main"; -import { prepareQuit } from "main/index"; +import { prepareIntent } from "main/lib/lifecycle"; import { prerelease } from "semver"; import { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { PLATFORM } from "shared/constants"; @@ -91,8 +91,9 @@ export function installUpdate(): void { emitStatus(AUTO_UPDATE_STATUS.IDLE); return; } - // quitAndInstall internally calls app.quit() — set mode beforehand - prepareQuit("release"); + // quitAndInstall internally calls app.quit() — set intent beforehand + // so before-quit skips background-to-tray and performs a real exit. + prepareIntent("install_update"); autoUpdater.quitAndInstall(false, true); } diff --git a/apps/desktop/src/main/lib/lifecycle.ts b/apps/desktop/src/main/lib/lifecycle.ts new file mode 100644 index 00000000000..98c4371b8a9 --- /dev/null +++ b/apps/desktop/src/main/lib/lifecycle.ts @@ -0,0 +1,60 @@ +import { app } from "electron"; + +/** + * Lifecycle intents determine how quit requests are handled. + * + * When no intent is set, macOS implicit quit (Cmd+Q, Dock Quit, window close) + * backgrounds to tray by destroying windows and keeping the process alive. + * + * Explicit intents trigger full process termination: + * - exit_release: release host services (re-adoptable on next launch), full exit + * - exit_stop: stop host services, full exit + * - install_update: release services, let updater handle shutdown/install + * - restart: release services, relaunch app, full exit + */ +export type LifecycleIntent = + | "exit_release" + | "exit_stop" + | "install_update" + | "restart"; + +let pendingIntent: LifecycleIntent | null = null; +let exiting = false; + +/** Request a full exit with the given intent. Triggers app.quit(). */ +export function requestExit( + intent: "exit_release" | "exit_stop" | "restart", +): void { + pendingIntent = intent; + app.quit(); +} + +/** + * Set an intent without triggering quit. + * Use when another API (e.g. autoUpdater.quitAndInstall) triggers quit internally. + */ +export function prepareIntent(intent: LifecycleIntent): void { + pendingIntent = intent; +} + +/** Consume and return the pending intent. Resets to null after reading. */ +export function consumeIntent(): LifecycleIntent | null { + const intent = pendingIntent; + pendingIntent = null; + return intent; +} + +export function markExiting(): void { + exiting = true; +} + +export function isExiting(): boolean { + return exiting; +} + +/** Any non-null intent requires full process termination. */ +export function isFullExitIntent( + intent: LifecycleIntent | null, +): intent is LifecycleIntent { + return intent !== null; +} diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 0cc335788ba..e9e19cfde1f 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -7,7 +7,8 @@ import { nativeImage, Tray, } from "electron"; -import { focusMainWindow, requestQuit } from "main/index"; +import { focusMainWindow } from "main/index"; +import { requestExit } from "main/lib/lifecycle"; import { getHostServiceManager, type HostServiceStatus, @@ -239,18 +240,18 @@ function updateTrayMenu(): void { ...(hasActive ? [ { - label: "Quit (Keep Services Running)", - click: () => requestQuit("release"), + label: "Quit Superset", + click: () => requestExit("exit_release"), }, { label: "Quit & Stop Services", - click: () => requestQuit("stop"), + click: () => requestExit("exit_stop"), }, ] : [ { - label: "Quit", - click: () => requestQuit("release"), + label: "Quit Superset", + click: () => requestExit("exit_release"), }, ]), ]); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 00131f6606a..0618b5f387e 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -38,6 +38,13 @@ import { getWorkspaceRuntimeRegistry } from "../lib/workspace-runtime"; // Singleton IPC handler to prevent duplicate handlers on window reopen (macOS) let ipcHandler: ReturnType | null = null; +// Notification HTTP server survives window lifecycle — only created once. +let notificationServer: ReturnType | null = + null; + +// Tracked for cleanup when the window is destroyed and recreated. +let activeNotificationManager: NotificationManager | null = null; + function getWorkspaceNameFromDb(workspaceId: string | undefined): string { if (!workspaceId) return "Workspace"; try { @@ -144,15 +151,28 @@ export async function MainWindow() { }); } - const server = notificationsApp.listen( - env.DESKTOP_NOTIFICATIONS_PORT, - "127.0.0.1", - () => { - console.log( - `[notifications] Listening on http://127.0.0.1:${env.DESKTOP_NOTIFICATIONS_PORT}`, - ); - }, - ); + // Notification server is a long-lived singleton that survives window destruction. + // On macOS, windows are destroyed on background-to-tray and recreated on reopen. + if (!notificationServer) { + notificationServer = notificationsApp.listen( + env.DESKTOP_NOTIFICATIONS_PORT, + "127.0.0.1", + () => { + console.log( + `[notifications] Listening on http://127.0.0.1:${env.DESKTOP_NOTIFICATIONS_PORT}`, + ); + }, + ); + } + + // Dispose previous notification manager from a destroyed window + if (activeNotificationManager) { + activeNotificationManager.dispose(); + activeNotificationManager = null; + } + + // Clear stale event listeners from previous window lifecycle + notificationsEmitter.removeAllListeners(); const notificationManager = new NotificationManager({ isSupported: () => Notification.isSupported(), @@ -180,6 +200,7 @@ export async function MainWindow() { }), }); notificationManager.start(); + activeNotificationManager = notificationManager; notificationsEmitter.on( NOTIFICATION_EVENTS.AGENT_LIFECYCLE, @@ -297,8 +318,10 @@ export async function MainWindow() { console.error(` Error:`, error); }); - window.on("close", (event) => { - // Save window state first, before any cleanup + // Save window state on close (fires for normal close, not for destroy). + // The debounced save on move/resize already persists state continuously, + // so the destroy path (Cmd+Q background) still has recent state. + window.on("close", () => { const isMaximized = window.isMaximized(); const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); const zoomLevel = window.webContents.getZoomLevel(); @@ -311,19 +334,18 @@ export async function MainWindow() { zoomLevel, }); persistedZoomLevel = zoomLevel; + }); - // macOS: hide instead of destroy so "Open Superset" can reshow instantly. - // The quit flow uses app.exit(0) which bypasses close events entirely, - // so this hide path only runs for Cmd+W / red-X. - if (PLATFORM.IS_MAC) { - event.preventDefault(); - window.hide(); - return; - } - + // Clean up resources when the window is destroyed. + // Fires for both normal close and destroy() (background-to-tray). + // The notification server is intentionally NOT closed — it survives + // window lifecycle so it's available immediately on window recreation. + window.on("closed", () => { browserManager.unregisterAll(); - server.close(); - notificationManager.dispose(); + if (activeNotificationManager) { + activeNotificationManager.dispose(); + activeNotificationManager = null; + } notificationsEmitter.removeAllListeners(); getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners(); ipcHandler?.detachWindow(window); From 8144e5bb2f6d4ceacabcc673d02975e0476ee668 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 5 Apr 2026 22:11:59 -0700 Subject: [PATCH 2/5] simplify: remove macOS background-to-tray, all quit paths fully exit Cmd+Q / Dock Quit now fully quit like any other app. The lifecycle intents remain for the updater fix (install_update) and restart, but implicit quit no longer backgrounds to tray or leaves the dock icon. --- .../lib/electron-app/factories/app/setup.ts | 2 +- apps/desktop/src/main/index.ts | 12 +--------- apps/desktop/src/main/windows/main.ts | 23 +++++++++++-------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index c8e4f26aab1..05a1138209e 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -33,7 +33,7 @@ export async function makeAppSetup( if (!windows.length) { window = await createWindow(); } else { - // Focus existing windows (e.g. minimized or behind other apps) + // Show hidden windows (macOS hide-to-tray) or restore minimized ones for (window of windows.reverse()) { window.show(); window.focus(); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4e59b66dc1c..0278c148223 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -171,17 +171,7 @@ app.on("before-quit", async (event) => { // Consume the intent so it doesn't persist across aborted quits const intent = consumeIntent(); - // macOS: implicit quit (no intent) backgrounds to tray. - // Destroy all windows but keep the tray and process alive. - if (PLATFORM.IS_MAC && !isFullExitIntent(intent)) { - event.preventDefault(); - for (const win of BrowserWindow.getAllWindows()) { - win.destroy(); - } - return; - } - - // Windows/Linux implicit quit: optionally confirm before exiting + // Implicit quit (no intent): optionally confirm before exiting if (!isFullExitIntent(intent)) { const isDev = process.env.NODE_ENV === "development"; if (!isDev && getConfirmOnQuitSetting()) { diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 0618b5f387e..bff0540f48f 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -318,10 +318,8 @@ export async function MainWindow() { console.error(` Error:`, error); }); - // Save window state on close (fires for normal close, not for destroy). - // The debounced save on move/resize already persists state continuously, - // so the destroy path (Cmd+Q background) still has recent state. - window.on("close", () => { + window.on("close", (event) => { + // Save window state first, before any cleanup const isMaximized = window.isMaximized(); const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); const zoomLevel = window.webContents.getZoomLevel(); @@ -334,14 +332,19 @@ export async function MainWindow() { zoomLevel, }); persistedZoomLevel = zoomLevel; - }); - // Clean up resources when the window is destroyed. - // Fires for both normal close and destroy() (background-to-tray). - // The notification server is intentionally NOT closed — it survives - // window lifecycle so it's available immediately on window recreation. - window.on("closed", () => { + // macOS: hide instead of destroy so "Open Superset" can reshow instantly. + // The quit flow uses app.exit(0) which bypasses close events entirely, + // so this hide path only runs for Cmd+W / red-X. + if (PLATFORM.IS_MAC) { + event.preventDefault(); + window.hide(); + return; + } + browserManager.unregisterAll(); + notificationServer?.close(); + notificationServer = null; if (activeNotificationManager) { activeNotificationManager.dispose(); activeNotificationManager = null; From c9a44975c158821992eb479cefb877dc921d73db Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 5 Apr 2026 22:23:36 -0700 Subject: [PATCH 3/5] simplify: remove install_update intent and notification server singleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without background-to-tray, the updater doesn't need a special intent — exit_release works fine since the updater does its install before triggering quit. Revert window notification server to simple local variable since windows are hidden (not destroyed) on macOS close. --- apps/desktop/src/main/index.ts | 18 ++------- apps/desktop/src/main/lib/auto-updater.ts | 4 +- apps/desktop/src/main/lib/lifecycle.ts | 25 +++--------- apps/desktop/src/main/windows/main.ts | 47 ++++++----------------- 4 files changed, 21 insertions(+), 73 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0278c148223..7cfa4a1255b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -29,12 +29,7 @@ import { resolveDevWorkspaceName } from "./lib/dev-workspace-name"; import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; import { getHostServiceManager } from "./lib/host-service-manager"; -import { - consumeIntent, - isExiting, - isFullExitIntent, - markExiting, -} from "./lib/lifecycle"; +import { consumeIntent, isExiting, markExiting } from "./lib/lifecycle"; import { localDb } from "./lib/local-db"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { initSentry } from "./lib/sentry"; @@ -171,8 +166,8 @@ app.on("before-quit", async (event) => { // Consume the intent so it doesn't persist across aborted quits const intent = consumeIntent(); - // Implicit quit (no intent): optionally confirm before exiting - if (!isFullExitIntent(intent)) { + // Implicit quit (no explicit intent): optionally confirm before exiting + if (intent === null) { const isDev = process.env.NODE_ENV === "development"; if (!isDev && getConfirmOnQuitSetting()) { event.preventDefault(); @@ -196,7 +191,6 @@ app.on("before-quit", async (event) => { } } - // Full exit path markExiting(); const manager = getHostServiceManager(); @@ -208,12 +202,6 @@ app.on("before-quit", async (event) => { disposeTray(); - if (intent === "install_update") { - // Let the updater own the final quit/install sequence. - // Don't call app.exit(0) — autoUpdater.quitAndInstall() handles it. - return; - } - if (intent === "restart") { app.relaunch(); } diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 5afa7c326b0..2b7f886d76b 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -92,8 +92,8 @@ export function installUpdate(): void { return; } // quitAndInstall internally calls app.quit() — set intent beforehand - // so before-quit skips background-to-tray and performs a real exit. - prepareIntent("install_update"); + // so before-quit skips the confirm dialog and exits cleanly. + prepareIntent("exit_release"); autoUpdater.quitAndInstall(false, true); } diff --git a/apps/desktop/src/main/lib/lifecycle.ts b/apps/desktop/src/main/lib/lifecycle.ts index 98c4371b8a9..2fdd4c2c0f2 100644 --- a/apps/desktop/src/main/lib/lifecycle.ts +++ b/apps/desktop/src/main/lib/lifecycle.ts @@ -1,30 +1,22 @@ import { app } from "electron"; /** - * Lifecycle intents determine how quit requests are handled. + * Lifecycle intents for explicit quit actions. * - * When no intent is set, macOS implicit quit (Cmd+Q, Dock Quit, window close) - * backgrounds to tray by destroying windows and keeping the process alive. + * When no intent is set, implicit quit (Cmd+Q, Dock Quit) goes through + * the default before-quit path (confirm dialog, release services, exit). * - * Explicit intents trigger full process termination: * - exit_release: release host services (re-adoptable on next launch), full exit * - exit_stop: stop host services, full exit - * - install_update: release services, let updater handle shutdown/install * - restart: release services, relaunch app, full exit */ -export type LifecycleIntent = - | "exit_release" - | "exit_stop" - | "install_update" - | "restart"; +export type LifecycleIntent = "exit_release" | "exit_stop" | "restart"; let pendingIntent: LifecycleIntent | null = null; let exiting = false; /** Request a full exit with the given intent. Triggers app.quit(). */ -export function requestExit( - intent: "exit_release" | "exit_stop" | "restart", -): void { +export function requestExit(intent: LifecycleIntent): void { pendingIntent = intent; app.quit(); } @@ -51,10 +43,3 @@ export function markExiting(): void { export function isExiting(): boolean { return exiting; } - -/** Any non-null intent requires full process termination. */ -export function isFullExitIntent( - intent: LifecycleIntent | null, -): intent is LifecycleIntent { - return intent !== null; -} diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index bff0540f48f..00131f6606a 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -38,13 +38,6 @@ import { getWorkspaceRuntimeRegistry } from "../lib/workspace-runtime"; // Singleton IPC handler to prevent duplicate handlers on window reopen (macOS) let ipcHandler: ReturnType | null = null; -// Notification HTTP server survives window lifecycle — only created once. -let notificationServer: ReturnType | null = - null; - -// Tracked for cleanup when the window is destroyed and recreated. -let activeNotificationManager: NotificationManager | null = null; - function getWorkspaceNameFromDb(workspaceId: string | undefined): string { if (!workspaceId) return "Workspace"; try { @@ -151,28 +144,15 @@ export async function MainWindow() { }); } - // Notification server is a long-lived singleton that survives window destruction. - // On macOS, windows are destroyed on background-to-tray and recreated on reopen. - if (!notificationServer) { - notificationServer = notificationsApp.listen( - env.DESKTOP_NOTIFICATIONS_PORT, - "127.0.0.1", - () => { - console.log( - `[notifications] Listening on http://127.0.0.1:${env.DESKTOP_NOTIFICATIONS_PORT}`, - ); - }, - ); - } - - // Dispose previous notification manager from a destroyed window - if (activeNotificationManager) { - activeNotificationManager.dispose(); - activeNotificationManager = null; - } - - // Clear stale event listeners from previous window lifecycle - notificationsEmitter.removeAllListeners(); + const server = notificationsApp.listen( + env.DESKTOP_NOTIFICATIONS_PORT, + "127.0.0.1", + () => { + console.log( + `[notifications] Listening on http://127.0.0.1:${env.DESKTOP_NOTIFICATIONS_PORT}`, + ); + }, + ); const notificationManager = new NotificationManager({ isSupported: () => Notification.isSupported(), @@ -200,7 +180,6 @@ export async function MainWindow() { }), }); notificationManager.start(); - activeNotificationManager = notificationManager; notificationsEmitter.on( NOTIFICATION_EVENTS.AGENT_LIFECYCLE, @@ -343,12 +322,8 @@ export async function MainWindow() { } browserManager.unregisterAll(); - notificationServer?.close(); - notificationServer = null; - if (activeNotificationManager) { - activeNotificationManager.dispose(); - activeNotificationManager = null; - } + server.close(); + notificationManager.dispose(); notificationsEmitter.removeAllListeners(); getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners(); ipcHandler?.detachWindow(window); From d0eec1f322bc79d8102bdbf7914b43632cf9f123 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 5 Apr 2026 22:24:40 -0700 Subject: [PATCH 4/5] docs(desktop): consolidate tray lifecycle docs into single decision doc Replace three separate research/analysis/migration docs with one consolidated doc reflecting the current decision: all quit paths fully exit, background-to-tray deferred pending process split. --- ...0260405-macos-tray-short-term-migration.md | 429 ------------------ .../20260405-quit-tray-current-vs-target.md | 229 ---------- .../plans/20260405-quit-tray-lifecycle.md | 78 ++++ .../20260405-tray-architecture-research.md | 306 ------------- 4 files changed, 78 insertions(+), 964 deletions(-) delete mode 100644 apps/desktop/plans/20260405-macos-tray-short-term-migration.md delete mode 100644 apps/desktop/plans/20260405-quit-tray-current-vs-target.md create mode 100644 apps/desktop/plans/20260405-quit-tray-lifecycle.md delete mode 100644 apps/desktop/plans/20260405-tray-architecture-research.md diff --git a/apps/desktop/plans/20260405-macos-tray-short-term-migration.md b/apps/desktop/plans/20260405-macos-tray-short-term-migration.md deleted file mode 100644 index 1546038ef0e..00000000000 --- a/apps/desktop/plans/20260405-macos-tray-short-term-migration.md +++ /dev/null @@ -1,429 +0,0 @@ -# Short-Term Migration: macOS Tray-Resident Lifecycle - -## Objective - -In the short term, we want a consistent macOS lifecycle where: - -- closing the last window -- quitting from the Dock -- pressing `Cmd+Q` -- other normal app quit requests - -all tear down the app UI and leave Superset resident in the tray. - -At the same time, we must preserve a separate path for: - -- explicit full exit -- explicit full exit + stop services -- install update -- restart app - -because those paths need the process to actually terminate. - -## Why this needs a migration - -Today, Superset already has some tray-resident behavior, but it is inconsistent and it breaks update install. - -### Current behavior summary - -1. **Window close hides the existing window** - - `apps/desktop/src/main/windows/main.ts` - - On macOS, window close calls `window.hide()`, so the current `BrowserWindow` is retained. - -2. **Implicit quit only backgrounds to tray when active host-services exist** - - `apps/desktop/src/main/index.ts` - - In `before-quit`, macOS returns early and keeps tray alive only when: - - quit mode is `null` or `"release"` - - `manager.hasActiveInstances()` is true - -3. **Otherwise quit fully exits** - - same file - - The final branch calls: - - `releaseAll()` or `stopAll()` - - `disposeTray()` - - `app.exit(0)` - -4. **Updater install is currently broken by the quit interception** - - `apps/desktop/src/main/lib/auto-updater.ts` - - `installUpdate()` does: - - `prepareQuit("release")` - - `autoUpdater.quitAndInstall(false, true)` - - But the macOS `before-quit` branch treats `"release"` as "destroy windows and keep tray alive" when services are active, so the app never fully quits for install. - -## Short-term target - -This migration stays within a **single Electron app/runtime**. It does **not** attempt the full Docker-style split-process architecture yet. - -The short-term target is: - -- introduce a long-lived tray owner inside the current Electron runtime -- separate tray lifetime from window lifetime -- destroy windows on background transitions instead of hiding them -- recreate windows on demand from tray / activate -- keep full-exit/update/restart as explicit separate flows - -## Non-goals - -- No separate native tray process yet -- No full Docker-style split runtime yet -- No host-service architecture rewrite -- No Windows/Linux tray behavior redesign in this phase - -## Product model after migration - -After this migration, we should have two categories of lifecycle action. - -### 1. Background-to-tray actions - -These should destroy windows and keep the tray alive: - -- close last window -- Dock Quit -- `Cmd+Q` -- app menu Quit - -### 2. Full-exit actions - -These should terminate the process: - -- explicit tray "Quit Superset" -- explicit tray "Quit & Stop Services" -- update install -- restart app - -This is the key separation missing today. - -## Architectural changes - -## 1. Introduce a long-lived tray owner - -Create a dedicated tray lifecycle module that is initialized once after `app.whenReady()` and remains alive until a true full exit. - -Responsibilities: - -- create and own the tray -- rebuild tray menu -- handle tray-driven actions -- expose "open/create window" action -- survive window teardown -- only dispose on true full exit - -This is different from the current setup where tray init/dispose is implicitly tied to the central quit path. - -## 2. Separate tray lifetime from window lifetime - -Introduce a window lifecycle owner separate from the tray owner. - -Responsibilities: - -- create a fresh main window -- destroy all windows -- report whether any UI windows exist -- recreate UI on tray click / activate - -Important behavioral change: - -- backgrounding to tray should **destroy** windows -- it should **not** hide and retain the existing `BrowserWindow` - -## 3. Introduce explicit lifecycle intents - -The short-term migration should stop overloading `"release"` to mean both: - -- "background to tray" -- "release services and fully exit" - -We need explicit intents, for example: - -- `background_to_tray` -- `exit_release` -- `exit_stop` -- `install_update` -- `restart` - -The exact enum names are flexible, but the separation is required. - -## Proposed control plane - -Add a central lifecycle coordinator that translates intents into actions. - -### Background path - -For `background_to_tray`: - -- prevent app quit -- destroy all windows -- keep tray alive -- keep process alive -- do not call `disposeTray()` -- do not call `app.exit(0)` - -### Full exit path - -For `exit_release`: - -- release services -- dispose tray -- quit process - -For `exit_stop`: - -- stop services -- dispose tray -- quit process - -For `install_update`: - -- release services if needed -- dispose tray -- allow updater-managed shutdown/install to proceed -- do **not** convert to background-to-tray - -For `restart`: - -- relaunch app -- dispose tray -- fully exit - -## Updater-specific migration - -This needs to be treated as first-class scope, because the current update path is already broken. - -## Current updater bug - -Current code: - -- `installUpdate()` sets `prepareQuit("release")` -- then calls `autoUpdater.quitAndInstall(false, true)` - -Current macOS quit behavior: - -- `"release"` can be intercepted and turned into "destroy windows, keep tray alive" - -Result: - -- update install requests can end up backgrounding the app instead of quitting it -- the downloaded update remains ready but does not install - -## Official updater behavior we need to respect - -Electron's auto-updater docs say: - -- `quitAndInstall()` closes application windows first, then automatically calls `app.quit()` -- `before-quit-for-update` exists because `before-quit` does not run before windows close in the normal way during update shutdown - -References: - -- Electron autoUpdater docs: -- electron-updater docs: - -This means our current "generic `before-quit` intercept everything" approach is the wrong shape for updates. - -## Required updater changes - -### 1. Stop using `"release"` as the update-install signal - -Do not model update install as `"release"`. - -It needs its own explicit lifecycle intent, e.g. `install_update`. - -### 2. Skip background-to-tray logic for update install - -When update install is in progress: - -- do not hit the macOS background-to-tray branch -- do not leave tray resident -- do not prevent the process from actually terminating - -### 3. Let the updater own the final quit/install sequence - -For update install, the app should prepare state and cleanup, but it should not replace the updater's quit/install sequence with a generic `app.exit(0)` branch. - -Short-term rule: - -- updater path prepares for shutdown -- updater still owns `quitAndInstall()` - -### 4. Add dedicated update-shutdown cleanup - -We should add a dedicated update cleanup path, likely keyed off: - -- explicit lifecycle intent `install_update` -- and, if surfaced cleanly through the current updater package, `before-quit-for-update` - -That cleanup path should: - -- release host services if they are meant to survive update/relaunch -- dispose tray -- mark the app as truly quitting -- avoid background fallback behavior - -## Tray UX changes required - -If macOS `Cmd+Q` / Dock Quit now background to tray, then we still need an explicit way for the user to fully exit. - -Recommended tray actions after migration: - -- `Open Superset` -- `Quit Superset` -- `Quit & Stop Services` - -The current "Quit (Keep Services Running)" tray action becomes ambiguous once normal OS quit no longer exits. We should rename this to make the behavior explicit. - -Recommended meaning: - -- `Quit Superset` -> full exit, keep services running -- `Quit & Stop Services` -> full exit, stop services - -## Migration phases - -## Phase 1: Introduce lifecycle coordinator and separate owners - -Goal: - -- create the architectural seams without changing user-facing behavior yet - -Work: - -- add tray owner module -- add window owner module -- add lifecycle intent model -- route current tray actions through lifecycle coordinator - -Output: - -- tray lifecycle is no longer hard-coded directly into the generic quit branch - -## Phase 2: Convert macOS close and implicit quit into background-to-tray - -Goal: - -- make close / Dock Quit / `Cmd+Q` destroy windows and leave tray alive - -Work: - -- replace `window.hide()` on macOS close with background-to-tray teardown -- remove `hasActiveInstances()` gating from implicit quit-to-tray behavior -- make reopen create a fresh window rather than re-showing a retained hidden one - -Output: - -- one consistent macOS background behavior - -## Phase 3: Fix explicit full-exit paths - -Goal: - -- preserve true exit semantics where they are actually needed - -Work: - -- keep explicit tray full-exit action -- keep explicit tray full-exit-and-stop action -- route restart through full-exit lifecycle - -Output: - -- background and full-exit semantics are no longer mixed together - -## Phase 4: Fix updater install path - -Goal: - -- make install update always perform a real quit/install sequence - -Work: - -- replace updater's current `"release"` quit preparation with dedicated `install_update` intent -- skip background-to-tray logic during update install -- add dedicated update cleanup path -- ensure tray is disposed on update install -- ensure host-service release/cleanup is deliberate and not inherited accidentally from generic quit - -Output: - -- clicking `Install` from update UI actually installs the update - -## Phase 5: Verification and cleanup - -Goal: - -- lock behavior down with tests and a manual verification checklist - -Work: - -- unit/integration coverage around lifecycle coordinator -- updater install path verification -- tray reopen verification -- close/quit behavior verification with and without active services - -## Acceptance criteria - -The migration is done when all of the following are true on macOS: - -1. Closing the last window leaves no open windows and the tray remains alive. -2. `Cmd+Q` leaves no open windows and the tray remains alive. -3. Dock Quit leaves no open windows and the tray remains alive. -4. Reopening from tray creates a fresh window successfully. -5. This behavior does not depend on `hasActiveInstances()`. -6. There is still an explicit tray action that fully exits the app. -7. There is still an explicit tray action that fully exits and stops services. -8. Clicking update install fully quits and installs the update. -9. Update install does not get stuck in a tray-resident state. - -## Manual verification checklist - -### Background-to-tray checks - -- Launch app with no active services, close last window -> tray stays alive, no windows remain -- Launch app with active services, close last window -> same result -- Launch app with no active services, press `Cmd+Q` -> tray stays alive, no windows remain -- Launch app with active services, press `Cmd+Q` -> same result -- Use Dock Quit with and without active services -> same result -- Reopen from tray after each case -> fresh window appears - -### Full-exit checks - -- Use explicit tray full exit -> process terminates, tray disappears, services remain running if expected -- Use explicit tray full exit + stop -> process terminates, tray disappears, services stop - -### Update checks - -- Simulate update ready, click install -> app actually quits for install -- Verify update path does not leave the app sitting in tray-only state - -## Risks - -### 1. macOS convention risk - -Making `Cmd+Q` background to tray is non-standard for many macOS apps. This is intentional here, but it is still a product decision and should be treated as such. - -### 2. Regressions from switching hide -> destroy - -Destroying windows instead of hiding them changes: - -- window state retention behavior -- subscriptions/listeners tied to window instances -- assumptions in reopen flows - -That is why the window owner split is important. - -### 3. Updater lifecycle complexity - -Update install is not just another quit path. It has distinct semantics and Electron explicitly documents that its event sequence differs from normal quit. - -We should not keep treating it as a special case of `"release"`. - -## Recommended implementation order - -1. Introduce lifecycle intents and tray/window owners -2. Move implicit macOS close/quit to background-to-tray -3. Add explicit full-exit tray action naming -4. Fix updater install path with dedicated intent -5. Add tests and verification checklist - -## References - -- Electron autoUpdater docs: -- electron-builder auto update docs: -- electron-updater API docs: diff --git a/apps/desktop/plans/20260405-quit-tray-current-vs-target.md b/apps/desktop/plans/20260405-quit-tray-current-vs-target.md deleted file mode 100644 index 6ff7b9f2d36..00000000000 --- a/apps/desktop/plans/20260405-quit-tray-current-vs-target.md +++ /dev/null @@ -1,229 +0,0 @@ -# macOS Quit / Tray Lifecycle: Current Behavior vs Target - -## Goal - -Align macOS behavior so that: - -- closing the last window -- quitting from the Dock -- pressing `Cmd+Q` -- handling the normal quit path / quit signal - -all remove the app UI while keeping the tray alive. - -## Architectural direction - -The fix is not just "change a few quit branches." We want to separate tray lifetime from window lifetime. - -The target architecture is: - -- a long-lived tray/background module owns tray lifecycle -- window lifecycle becomes separate from tray lifecycle -- closing or quitting tears down app UI windows -- tray/background runtime stays alive and can recreate windows later - -In other words, the tray should not be treated as something owned by the current main-window lifecycle. - -### Practical constraint - -If "kill main" means "tear down the current app UI / window management flow", that is achievable with a long-lived tray/background module. - -If "kill main" means "terminate the Electron process that owns the tray", then the tray still cannot survive that. A true tray-survives-process-exit design would require a separate background entrypoint/process, not just a module split. - -For this note, the intended target is: - -- destroy the main window(s) / app UI -- keep a long-lived tray/background runtime resident -- keep the tray alive - -## Current behavior - -### 1. Closing the last window on macOS hides it, it does not destroy it - -In `apps/desktop/src/main/windows/main.ts`, the window `close` handler: - -- saves window state -- calls `event.preventDefault()` -- calls `window.hide()` - -Result: - -- the existing `BrowserWindow` stays alive in memory -- the app process stays alive -- the tray stays alive -- re-open is effectively a re-show of the same hidden window - -This is a hide-to-tray style behavior, not a destroy-window / background-only behavior. - -### 2. Standard app quit (`Cmd+Q`, Dock Quit, app menu Quit) goes through `before-quit` - -In `apps/desktop/src/main/index.ts`, standard quit calls `app.quit()`, which reaches the central `before-quit` handler. - -That handler currently behaves differently depending on state. - -#### Case A: macOS + active host-service instances + quit mode `null` or `"release"` - -The quit is prevented and Electron: - -- destroys all `BrowserWindow`s -- returns early -- leaves the tray alive -- leaves the app process alive -- leaves host-services alive - -This is the one path that already matches the desired shape fairly closely. - -#### Case B: no active instances, or a path that falls through the early return - -The app continues through the final exit path: - -- optional quit confirmation for implicit quit -- `releaseAll()` or `stopAll()` -- `disposeTray()` -- `app.exit(0)` - -Result: - -- app process exits -- tray dies - -So today, standard quit only becomes "background to tray" when active host-service instances exist. - -### 3. Tray quit uses explicit quit modes - -The tray menu currently calls: - -- `requestQuit("release")` -- `requestQuit("stop")` - -Those modes still route into the same `before-quit` logic above. - -So tray quit is not its own lifecycle model; it is just a different entry point into the central quit handler. - -### 4. Some exit paths bypass `before-quit` - -There are direct exit paths that currently do not align with a tray-resident target: - -- dev `SIGTERM` / `SIGINT` handlers call `app.exit(0)` directly -- app restart calls `app.relaunch()` then `exitImmediately()` - -Those paths terminate the Electron process instead of transitioning to tray. - -## Desired target behavior - -The desired macOS model is: - -- closing the last window destroys the window UI instead of hiding it -- `Cmd+Q` destroys the window UI instead of exiting the whole app -- Dock Quit destroys the window UI instead of exiting the whole app -- the tray remains alive after any of those actions -- this behavior should not depend on `hasActiveInstances()` - -Operationally, the target is: - -- no visible app windows -- no hidden retained main window -- long-lived tray/background runtime still alive -- tray still alive -- app can be reopened from tray / activate flow by creating a fresh window when needed - -### Structural implication - -To get there cleanly, the tray should move behind a dedicated long-lived module that: - -- owns tray creation and disposal -- survives window teardown -- handles re-open / create-window actions -- receives quit intents and decides whether they mean "destroy windows" or "full process exit" - -That is the layer that should remain alive when the last window closes or when macOS quit is converted into "background to tray." - -## Delta from current behavior - -### Delta 1: window close path is wrong today - -Current: - -- close last window -> `window.hide()` - -Target: - -- close last window -> destroy the window and leave only tray/background process - -### Delta 2: quit-to-tray is incorrectly gated on active services - -Current: - -- `Cmd+Q` / Dock Quit only stay alive if `manager.hasActiveInstances()` is true - -Target: - -- `Cmd+Q` / Dock Quit should background to tray regardless of host-service activity - -### Delta 3: normal quit still disposes the tray in common cases - -Current: - -- quit often falls through to `disposeTray()` + `app.exit(0)` - -Target: - -- normal macOS quit path should destroy windows but not dispose the long-lived tray/background module - -### Delta 4: current close path retains the existing window object - -Current: - -- the app keeps the existing hidden `BrowserWindow` - -Target: - -- the app should likely destroy window instances and recreate them on re-open - -This is an important semantic difference because "hidden existing window" and "no windows, tray-only app" are not the same lifecycle. - -### Delta 5: signal/direct-exit paths are not yet aligned - -Current: - -- some quit/exit paths call `app.exit(0)` directly - -Target: - -- any quit path that is supposed to behave like macOS quit-to-tray needs to route through the same tray/background lifecycle module instead of directly terminating Electron - -### Delta 6: tray ownership is coupled to the current main lifecycle - -Current: - -- tray init/dispose is directly wired into the existing app startup and quit flow -- tray lifetime is effectively tied to the current main lifecycle branches - -Target: - -- tray ownership should live in a separate long-lived module -- window teardown should not imply tray teardown -- full process exit should be an explicit, narrower path than "close or quit to tray" - -## Concrete summary - -Today there are effectively three macOS behaviors: - -1. window close -> hide existing window -2. quit with active services -> destroy windows, keep tray alive -3. quit without active services -> exit app and destroy tray - -The target is one consistent behavior: - -1. close or quit -> destroy windows, keep tray alive - -## Likely implementation surface - -The main files that define this behavior today are: - -- `apps/desktop/src/main/index.ts` -- `apps/desktop/src/main/windows/main.ts` -- `apps/desktop/src/lib/electron-app/factories/app/setup.ts` -- `apps/desktop/src/main/lib/tray/index.ts` - -If we implement the target, those files are where the lifecycle consolidation will happen. The main architectural change is to introduce a longer-lived tray/background owner and make window teardown route through it instead of treating tray disposal as part of the default quit path. diff --git a/apps/desktop/plans/20260405-quit-tray-lifecycle.md b/apps/desktop/plans/20260405-quit-tray-lifecycle.md new file mode 100644 index 00000000000..6b527810611 --- /dev/null +++ b/apps/desktop/plans/20260405-quit-tray-lifecycle.md @@ -0,0 +1,78 @@ +# macOS Quit & Tray Lifecycle + +## Decision (2025-04-05) + +All quit paths fully exit the app. No background-to-tray behavior for now. + +The tray exists while the app is running and provides host-service management and explicit quit actions. When the app quits, the tray goes away. + +### What shipped + +- **Lifecycle intents** (`exit_release`, `exit_stop`, `restart`) replace the overloaded `QuitMode` (`"release" | "stop"`). Explicit intents skip the confirm-on-quit dialog and route directly to the exit path. +- **Updater fix**: `installUpdate()` uses `prepareIntent("exit_release")` so `before-quit` skips the confirm dialog and exits cleanly. The old `prepareQuit("release")` was intercepted by the macOS background-to-tray block when services were active, preventing updates from installing. +- **Tray menu rename**: "Quit (Keep Services Running)" is now "Quit Superset" for clarity. +- **Restart consolidation**: `restartApp` tRPC endpoint uses `requestExit("restart")` instead of manual `app.relaunch()` + `app.exit(0)`. +- **Removed macOS background-to-tray block** from `before-quit`. The old block prevented quit and kept tray alive when `hasActiveInstances()` was true, but left the dock icon visible (confusing UX). + +### What was deferred + +Background-to-tray on macOS (Cmd+Q destroys windows but keeps tray alive) is the ideal target but was deferred because: + +1. **Dock icon stays visible** — macOS shows the dock icon as long as the Electron process is alive. Backgrounding to tray looks like the app is still running, which is confusing. +2. **Solving the dock icon requires a process split** — hiding the dock icon via `app.dock.hide()` has side effects (loses menu bar, loses Cmd+Tab). A clean solution requires a separate lightweight tray-host process, which is significant work. + +## Current behavior + +### Quit paths + +| Action | Behavior | +|--------|----------| +| Cmd+Q | Full exit (release services, dispose tray, exit) | +| Dock right-click Quit | Same | +| App menu Quit | Same | +| Window close (red-X / Cmd+W) | macOS: hide window (standard behavior). Non-macOS: close window, then app quits. | +| Tray "Quit Superset" | `requestExit("exit_release")` — release services, full exit | +| Tray "Quit & Stop Services" | `requestExit("exit_stop")` — stop services, full exit | +| Tray host-service "Stop" | Stops individual service, app stays running | +| Settings "Restart App" | `requestExit("restart")` — release services, relaunch, exit | +| Update install | `prepareIntent("exit_release")` + `quitAndInstall()` — full exit, updater handles install | + +### Host-service lifecycle on quit + +- **Release** (`exit_release`, implicit quit): services keep running as detached processes. On next app launch, they are re-adopted via manifest files. +- **Stop** (`exit_stop`): services are terminated via `SIGTERM`. + +### Key files + +- `src/main/lib/lifecycle.ts` — lifecycle intent model +- `src/main/index.ts` — `before-quit` handler +- `src/main/windows/main.ts` — window close behavior +- `src/main/lib/tray/index.ts` — tray menu and actions +- `src/main/lib/auto-updater.ts` — update install flow +- `src/lib/electron-app/factories/app/setup.ts` — `activate` / `window-all-closed` handlers + +## Future: tray-resident background + +If we want the tray to persist after quit (like Docker Desktop), there are two viable architectures: + +### Option A: Electron tray host + separate UI Electron + +A small Electron process owns the tray and spawns the main UI Electron app on demand. + +- Pros: shared JS/TS stack, easiest evolution from current code +- Cons: two Electron runtimes, packaging/update complexity + +### Option B: Native Swift tray host + Electron UI + +A native macOS menu bar app owns the tray. The Electron app is launched/attached on demand. + +- Pros: smallest memory footprint, cleanest separation +- Cons: native code, signing, IPC complexity + +Either option requires: +1. A separate long-lived process that owns the tray icon +2. Socket/named-pipe IPC between tray host and UI +3. A launch-on-login mechanism (launchd) +4. Update coordination between two processes + +This is medium-term work and not needed for the current product requirements. diff --git a/apps/desktop/plans/20260405-tray-architecture-research.md b/apps/desktop/plans/20260405-tray-architecture-research.md deleted file mode 100644 index 554da5d66de..00000000000 --- a/apps/desktop/plans/20260405-tray-architecture-research.md +++ /dev/null @@ -1,306 +0,0 @@ -# Tray Architecture Research: Electron Patterns vs Docker-Style Split Runtime - -## Question - -What pattern should we use if we want a tray that outlives the current Electron UI/main lifecycle, in a way that feels closer to Docker Desktop than to a typical "hide window to tray" Electron app? - -## Bottom line - -There are three distinct patterns in the wild: - -1. **Standard Electron tray app** - - One Electron main process owns tray, app lifecycle, and windows. - - Closing windows leaves the Electron app resident. - - Tray does **not** outlive Electron main. - -2. **Electron app with background worker/backend** - - Electron main still owns tray and windows. - - Long-lived work moves into a child Node/utility/backend process. - - Tray still does **not** outlive Electron main. - -3. **Docker-style split runtime** - - Background runtime and UI are decoupled. - - Tray belongs to the long-lived background runtime, not to the transient dashboard window flow. - - If we want the tray to outlive the current Electron UI/main lifecycle, this is the right family of architecture. - -The key implication is: - -- a separate **module** for tray is useful -- but a separate module alone is **not** enough to make the tray outlive the current Electron main lifecycle in a Docker-like way -- for that, we need a separate **long-lived runtime/process boundary**, not just a file split - -## What Electron itself supports - -### Tray is a main-process concern - -Electron's official tray guide creates the tray from `electron/main`, and its "minimizing to tray" pattern works by keeping the app alive when windows are closed. - -Sources: - -- Electron Tray guide: -- Electron Process Model: - -Relevant points from the docs: - -- Tray is created from the main process API surface. -- To keep tray alive when all windows are closed, Electron recommends handling `window-all-closed` without quitting. -- Electron has a single main process that controls application lifecycle and native APIs such as tray, dialogs, and menus. - -### Utility/background processes are supported, but they do not solve tray ownership - -Electron also supports child/utility processes for background work. - -Source: - -- Electron utility process API: - -That is useful for: - -- background services -- crash isolation -- expensive work - -But it does **not** move tray ownership out of the main-process lifecycle. It only helps split background computation away from UI. - -## Existing app patterns - -## Pattern A: Standard Electron tray-first app - -### Stretchly - -Stretchly is a cross-platform Electron app that explicitly describes itself as living in the tray and only showing windows when needed. - -Source: - -- Stretchly README: - -Relevant behavior: - -- "Stretchly itself lives in your tray" -- preferences are opened from tray -- the tray icon can even be disabled, which removes the graphical reopen path - -This is the classic Electron tray-first pattern: - -- keep Electron resident -- no requirement for a persistent main window -- spawn/show windows on demand - -This is a good reference for: - -- tray-owned app UX -- create-window-on-demand behavior - -It is **not** a reference for "tray outlives Electron main", because the tray still belongs to the one Electron app process. - -### Daily Electron Overlay demo - -Daily's Electron overlay demo also describes the app as living in the system tray. - -Source: - -- Daily electron-overlay README: - -This is the same family of design: - -- tray-first -- UI windows are secondary / transient -- Electron process remains alive - -Again: useful for tray-first UX, not for Docker-style runtime separation. - -## Pattern B: Electron app plus background backend process - -### `electron-with-server-example` - -James Long's example demonstrates an Electron app that forks a background Node process and communicates with it directly over a local socket. - -Source: - -- Example repo: - -Why this matters: - -- it is a clean example of separating durable backend work from Electron window lifecycle -- it shows a direct socket/named-pipe style IPC path instead of pushing everything through Electron IPC - -This is very relevant for Superset because it matches the shape of: - -- long-lived backend/runtime -- Electron shell as UI/controller - -But it still leaves tray ownership in Electron main. So it solves backend durability, not tray-survives-main. - -## Pattern C: Docker-style split runtime - -Docker Desktop is the best product reference for the UX we want, but its public docs do **not** expose a detailed internal Electron process diagram. So the following is partly direct evidence and partly inference from the product docs. - -Sources: - -- Explore Docker Desktop: -- Docker Desktop CLI: -- Docker Desktop settings: -- Docker Desktop install/uninstall docs: - - Windows install: - - Uninstall: - -### Direct evidence from Docker docs - -The docs show: - -- Docker Desktop has a tray/menu entry point ("The Docker menu") -- the Docker menu has a "Dashboard" action, which implies the tray/menu is not the same thing as the dashboard window -- Docker Desktop has a setting for "Open Docker Dashboard when Docker Desktop starts", which means startup and dashboard display are intentionally decoupled -- Docker Desktop exposes `docker desktop start|stop|restart|status` CLI commands -- Docker installs/runs background helpers and services such as `com.docker.service` on Windows and privileged helper tools like `com.docker.vmnetd` on macOS - -### Inference from those docs - -This strongly suggests a **split-runtime product model**: - -- long-lived desktop/background runtime -- optional/transient dashboard UI -- separate backend services/helpers - -That is the right conceptual target for us. - -What we should **not** infer without stronger evidence: - -- that Docker's tray is literally outside Electron -- that Docker uses a second Electron executable rather than a native helper -- exact process boundaries inside Docker Desktop - -But the docs are enough to support the larger architecture conclusion: - -- Docker is not using the basic "single Electron main owns everything forever" pattern as the product boundary -- the product boundary is "desktop runtime" first, "dashboard window" second - -## What this means for Superset - -## Option 1: Improve current Electron structure only - -We can: - -- create a dedicated tray lifecycle module -- make windows disposable and recreated on demand -- keep Electron resident with no windows - -This gets us to: - -- tray-first app -- no hidden retained main window -- better lifecycle separation than today - -This is a good incremental step. - -But it is still Pattern A: - -- tray survives window teardown -- tray does **not** survive Electron main exit - -## Option 2: Docker-style split runtime - -If the real requirement is: - -- tray/background survives the current UI/main lifecycle -- the dashboard window is just an attachable client - -then we need a real runtime split. - -### Recommended shape - -1. **Tray host runtime** - - long-lived - - owns tray/menu bar icon - - owns reopen/create-window actions - - receives quit/restart intents - - decides whether to destroy windows or fully exit - -2. **UI runtime** - - dashboard/workspace windows - - can be created/destroyed independently - - talks to tray host and backend over socket/named pipe IPC - -3. **Backend/service runtime** - - host services - - daemon/session/runtime management - - background work that should survive UI churn - -### Process model choices - -There are two realistic ways to do this: - -#### A. Small Electron tray host + separate UI Electron process - -Pros: - -- easiest if we want to reuse Electron for tray/menu APIs -- shared JS/TS stack -- simpler to evolve from current code - -Cons: - -- still more than one Electron runtime -- packaging/relaunch/update story becomes more complex - -#### B. Native tray host + Electron UI app - -Pros: - -- closest to a true Docker-style helper/desktop-runtime split -- smallest long-lived resident footprint -- strongest separation between tray/runtime and UI app - -Cons: - -- highest engineering cost -- native code, packaging, signing, and IPC complexity - -## Recommendation - -### Short term - -Do the structural cleanup inside the current app first: - -- introduce a tray lifecycle owner -- stop retaining a hidden main window -- make reopen create a fresh window -- route close / `Cmd+Q` / Dock Quit into one "destroy windows, keep tray runtime alive" path - -That gives us a cleaner tray-first architecture and removes the current lifecycle inconsistency. - -### Medium term - -If the product requirement remains "tray should outlive the current Electron main/UI lifecycle similar to Docker", plan for a process split: - -- tray host runtime -- UI runtime -- socket/named pipe IPC between them - -### Concrete recommendation for Superset - -Treat "separate tray module" as a **preparatory refactor**, not the final architecture. - -The final architecture should be described as: - -- **separate tray owner** -- **separate window owner** -- **eventually separate tray host runtime/process if we want true Docker-style behavior** - -## Decision guidance - -If the goal is only: - -- close/quit removes UI -- tray stays alive -- reopen creates a fresh window - -then a single resident Electron process is enough. - -If the goal is: - -- tray/runtime survives shutdown of the current Electron UI/main process -- the UI is attach/detach only -- closer to Docker Desktop's product boundary - -then we should design toward a separate tray host runtime/process. From c5b909cebec2a63cd1a788b0a818fd0a3e83cc28 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 5 Apr 2026 22:29:11 -0700 Subject: [PATCH 5/5] =?UTF-8?q?refactor(desktop):=20simplify=20to=20minima?= =?UTF-8?q?l=20fix=20=E2=80=94=20remove=20macOS=20background-to-tray=20blo?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert lifecycle module, notification server singleton, and all renaming. The only code change from main is removing the macOS before-quit block that intercepted quit when services were active. --- apps/desktop/HOST_SERVICE_BOUNDARIES.md | 13 +- apps/desktop/HOST_SERVICE_LIFECYCLE.md | 290 ++++-------------- .../src/lib/trpc/routers/settings/index.ts | 6 +- apps/desktop/src/main/index.ts | 90 +++--- apps/desktop/src/main/lib/auto-updater.ts | 7 +- apps/desktop/src/main/lib/lifecycle.ts | 45 --- apps/desktop/src/main/lib/tray/index.ts | 13 +- 7 files changed, 125 insertions(+), 339 deletions(-) delete mode 100644 apps/desktop/src/main/lib/lifecycle.ts diff --git a/apps/desktop/HOST_SERVICE_BOUNDARIES.md b/apps/desktop/HOST_SERVICE_BOUNDARIES.md index 43fe83e2820..21340029654 100644 --- a/apps/desktop/HOST_SERVICE_BOUNDARIES.md +++ b/apps/desktop/HOST_SERVICE_BOUNDARIES.md @@ -230,19 +230,12 @@ From coordinator (in-process): ### Actions ``` -Restart → coordinator.restart(organizationId) +Restart → coordinator.restart(organizationId, config) Stop → coordinator.stop(organizationId) -Quit (keep services) → requestQuit("release") → handled in `before-quit` -Quit (stop services) → requestQuit("stop") → handled in `before-quit` +Quit (keep services) → coordinator.releaseAll() + app.exit() +Quit (stop services) → coordinator.stopAll() + app.exit() ``` -The tray does not directly call `releaseAll()` or `stopAll()`. Current quit semantics live in -`apps/desktop/src/main/index.ts` and are platform/state dependent: - -- macOS + active services + `null`/`"release"` quit mode → destroy windows, keep tray/app alive -- final `"release"` exit path → `releaseAll()` + `disposeTray()` + `app.exit(0)` -- final `"stop"` exit path → `stopAll()` + `disposeTray()` + `app.exit(0)` - ### Menu structure ``` diff --git a/apps/desktop/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/HOST_SERVICE_LIFECYCLE.md index c57335192a0..3daf0d05dc4 100644 --- a/apps/desktop/HOST_SERVICE_LIFECYCLE.md +++ b/apps/desktop/HOST_SERVICE_LIFECYCLE.md @@ -1,251 +1,75 @@ # Host Service Lifecycle -This document describes the current v2 host-service quit, tray, and adoption behavior as implemented in the Electron desktop app today. +## Architecture -## Scope - -- v2 terminals and v2 workspace runtime go through host-service child processes. -- v1 terminals still use the separate `src/main/terminal-host/` daemon and do not follow this quit/tray path. -- The tray is macOS-only. - -## Main pieces +Electron main owns app lifecycle, tray, and host-service management. Host-services run as child processes that can outlive the app via manifest-based adoption. ``` -Electron main -├── `src/main/index.ts` -│ ├── owns `pendingQuitMode` -│ ├── handles `before-quit` -│ ├── discovers/adopts existing host-services on startup -│ └── initializes the tray -├── `src/main/lib/tray/index.ts` -│ ├── renders tray menu from HostServiceManager state -│ └── calls `requestQuit("release" | "stop")` -├── `src/main/lib/host-service-manager.ts` -│ ├── spawn / adopt / restart / stop / release -│ └── emits `status-changed` -└── `src/main/windows/main.ts` - └── macOS window close hides the window instead of destroying it +┌─────────────────────────────────────────────────────┐ +│ Electron Main Process │ +│ │ +│ ┌──────────┐ ┌──────────────────────┐ ┌───────┐ │ +│ │ Tray │ │ HostServiceManager │ │Windows│ │ +│ │ (macOS) │ │ │ │ │ │ +│ │ │◄─┤ status events │ │ hide/ │ │ +│ │ restart │ │ start/stop/adopt │ │ show │ │ +│ │ stop │ │ per org │ │ │ │ +│ │ quit ────┼──┼──► requestQuit(mode) │ │ │ │ +│ └──────────┘ └──────┬───────────────┘ └───────┘ │ +└───────────────────────┼─────────────────────────────┘ + │ IPC + stdio + ┌─────────────┼─────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │host-service│ │host-service│ │host-service│ + │ (org A) │ │ (org B) │ │ (org C) │ + │ │ │ │ │ │ + │ HTTP/tRPC │ │ HTTP/tRPC │ │ HTTP/tRPC │ + │ port:rand │ │ port:rand │ │ port:rand │ + │ │ │ │ │ │ + │ writes │ │ writes │ │ writes │ + │ manifest │ │ manifest │ │ manifest │ + └────────────┘ └────────────┘ └────────────┘ + │ │ │ + ▼ ▼ ▼ + ~/.superset/host/{orgId}/manifest.json ``` -## Startup order - -At app startup, the current sequence is: - -1. `app.whenReady()` -2. `getHostServiceManager().discoverAndAdoptAll()` -3. `makeAppSetup(() => MainWindow())` -4. `initTray()` - -That ordering matters: adopted services are loaded before tray init so the tray menu reflects already-running services immediately. - -## Service survival model - -Each host-service writes a manifest under `~/.superset/host/{orgId}/manifest.json` containing its pid, endpoint, auth token, version, and protocol version. - -- Release path: the desktop detaches from the child and leaves the manifest on disk. -- Next launch: `discoverAndAdoptAll()` scans manifests, checks pid liveness and `/trpc/health.check`, then adopts healthy services. -- Stop path: the desktop sends `SIGTERM`; the service is expected to remove its manifest during shutdown. -- Adopted services are polled for liveness every 5 seconds. If an adopted pid dies, the manager marks it `degraded` and schedules a restart. - -## Window lifecycle on macOS - -There are two distinct macOS "stay alive" paths: - -### 1. Window close (`Cmd+Shift+Q` in this app's menu, red close button) - -`src/main/windows/main.ts` intercepts `window.close` and: - -- saves window bounds -- calls `event.preventDefault()` -- hides the window - -This keeps the existing window object, app process, tray, and services alive. `app.on("activate")` or "Open Superset" in the tray re-shows the hidden window. - -### 2. App quit (`Cmd+Q`, app menu Quit, updater quit, tray quit) - -`src/main/index.ts` handles this in `before-quit`. This path can destroy windows, release services, stop services, or exit immediately depending on state. - -## Quit entry points - -### Standard app quit - -The normal app menu uses Electron's `role: "quit"`, so it calls `app.quit()` with no explicit quit mode. - -- `pendingQuitMode === null` -- confirm-on-quit may run -- macOS special-case can keep the tray alive if services are active - -### Tray quit - -The tray menu calls: - -- `requestQuit("release")` for "Quit (Keep Services Running)" -- `requestQuit("stop")` for "Quit & Stop Services" - -This sets `pendingQuitMode` before calling `app.quit()`. - -### Updater quit - -`installUpdate()` calls `prepareQuit("release")` and then `autoUpdater.quitAndInstall(false, true)`. - -This means updater installs currently reuse the same `"release"` `before-quit` path as tray release-quit. - -### App restart - -The settings router uses: - -- `app.relaunch()` -- `exitImmediately()` - -`exitImmediately()` calls `app.exit(0)` and bypasses `before-quit`, so it does not run `releaseAll()`, `stopAll()`, or `disposeTray()`. - -## Current `before-quit` flow - -`src/main/index.ts` is the single place that decides what app quit means. - -### Step 1: consume pending mode - -`before-quit` reads `pendingQuitMode` and immediately clears it so cancelled quits do not leave stale mode behind. - -Possible values: - -- `null` -- `"release"` -- `"stop"` - -### Step 2: macOS special-case when services are active - -If all of the following are true: +### Quit modes -- platform is macOS -- quit mode is `null` or `"release"` -- `manager.hasActiveInstances()` is true +All quit paths use a single `QuitMode` (`"release" | "stop"`): -then the app does **not** exit. Instead it: +- **release** — detach from services, they keep running for re-adoption on next launch +- **stop** — SIGTERM all services, then exit +- **implicit** (Cmd+Q with active services on macOS) — hide windows to tray -- calls `event.preventDefault()` -- destroys every `BrowserWindow` -- returns without disposing the tray +### Manifest adoption -Result: +Each host-service child writes `~/.superset/host/{orgId}/manifest.json` on startup (pid, endpoint, authToken, version). It's a pidfile extended with connection info. -- app process stays alive -- tray stays alive -- host-services stay running -- there are no open windows left +- **Release quit** — children keep running, manifests stay on disk +- **Next launch** — `discoverAndAdoptAll()` scans manifests, health-checks each pid/endpoint, reconnects if healthy, removes and respawns if not +- **Stop quit** — SIGTERM children, they remove their own manifests on shutdown -This is different from the macOS window-close hide path: - -- window close hides the window -- app quit destroys windows - -### Step 3: quit confirmation - -If quit mode is still `null`, the app is not in development, and the `confirmOnQuit` setting is enabled, a blocking confirm dialog is shown. - -If the user cancels: - -- `before-quit` returns -- the app remains running -- `pendingQuitMode` has already been cleared - -Explicit tray quit modes skip this dialog. - -### Step 4: final exit path - -If the quit continues: - -- set `isQuitting = true` -- `"stop"` -> `manager.stopAll()` -- `null` or `"release"` -> `manager.releaseAll()` -- `disposeTray()` -- `app.exit(0)` - -Important detail: `app.exit(0)` bypasses window close handlers, so per-window cleanup in `src/main/windows/main.ts` does not run during this path. - -## Non-macOS behavior - -- The tray is never initialized on Windows or Linux. -- `window-all-closed` calls `app.quit()`. -- `before-quit` still decides between release and stop. -- Release quit exits the app and leaves services running for next-launch adoption. - -## Tray behavior - -The tray is initialized only on macOS and only if the tray icon loads successfully. - -### Menu contents - -The tray currently shows: - -- Host Service (`N`) -- Open Superset -- Settings -- Check for Updates -- Quit actions - -If there are active services: - -- "Quit (Keep Services Running)" -- "Quit & Stop Services" - -Otherwise: - -- "Quit" - -Each active org section can show: - -- organization name -- status -- version -- uptime -- restart count -- update-required / update-available hints -- Restart action -- Stop action - -### Refresh behavior - -The tray menu refreshes from two sources: - -- immediately on `HostServiceManager` `status-changed` -- every 5 seconds via polling as a fallback - -## Current behavior that is easy to miss - -### 1. "Release quit" does not always exit on macOS - -On macOS, `"release"` plus active services does not go through `releaseAll() + app.exit(0)`. It hits the earlier `before-quit` branch that destroys windows and leaves the tray + app process alive. - -### 2. Implicit quit and explicit release quit share the same macOS branch - -Both of these hit the same branch when services are active: - -- standard quit with `pendingQuitMode === null` -- explicit tray/update `"release"` - -### 3. Tray release-quit and updater quit are coupled today - -Because the updater uses `prepareQuit("release")`, an update install currently takes the same macOS branch as tray release-quit when services are active. - -### 4. Restart bypasses the central quit logic - -`restartApp` does not flow through `before-quit`. It exits immediately after scheduling a relaunch. +``` +App Launch App Quit (release) Next Launch +───────── ────────────────── ─────────── +spawn child ──► child writes parent detaches scan manifests + manifest.json manifests stay on disk health-check pid/endpoint + {pid, endpoint, child keeps running ├─ healthy → reconnect + authToken, ...} └─ dead/bad → remove, respawn +``` -### 5. Window close and app quit are intentionally not the same thing +### v1 vs v2 terminal paths -On macOS: +v1 terminals run on a separate **terminal-host daemon** (`src/main/terminal-host/`) — a persistent background process that owns PTYs over a Unix domain socket. It has its own survival and reconnection model independent of host-service. -- close window -> hide current window -- quit app with active services -> destroy windows, keep tray alive -- final exit path -> dispose tray and `app.exit(0)` +v2 terminals run through **host-service** child processes. The quit/adopt/tray lifecycle described here only applies to host-service instances. -## Related files +### Design decisions -- `apps/desktop/src/main/index.ts` -- `apps/desktop/src/main/lib/tray/index.ts` -- `apps/desktop/src/main/lib/host-service-manager.ts` -- `apps/desktop/src/main/windows/main.ts` -- `apps/desktop/src/lib/electron-app/factories/app/setup.ts` -- `apps/desktop/src/main/lib/auto-updater.ts` -- `apps/desktop/src/lib/trpc/routers/settings/index.ts` +- **No supervisor process.** Electron main owns everything. Simpler while v1 and v2 coexist. +- **No tray on Windows/Linux.** Services still survive quit and are re-adopted, but there's no persistent UI to manage them. +- **Tray calls `requestQuit(mode)`.** One function, one codepath — no setter chains or flag mutation. +- **Manifest handling is single-sourced.** Both parent and child use `host-service-manifest.ts`. Files are written with 0o600 permissions. diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index d382420f5d4..5876d80fd22 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -16,7 +16,8 @@ import { DEFAULT_TERMINAL_PRESET_AGENT_TYPES, } from "@superset/shared/agent-command"; import { TRPCError } from "@trpc/server"; -import { requestExit } from "main/lib/lifecycle"; +import { app } from "electron"; +import { exitImmediately } from "main/index"; import { hasCustomRingtone } from "main/lib/custom-ringtones"; import { localDb } from "main/lib/local-db"; import { @@ -694,7 +695,8 @@ export const createSettingsRouter = () => { }), restartApp: publicProcedure.mutation(() => { - requestExit("restart"); + app.relaunch(); + exitImmediately(); return { success: true }; }), diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 7cfa4a1255b..797164ee659 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -29,7 +29,6 @@ import { resolveDevWorkspaceName } from "./lib/dev-workspace-name"; import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; import { getHostServiceManager } from "./lib/host-service-manager"; -import { consumeIntent, isExiting, markExiting } from "./lib/lifecycle"; import { localDb } from "./lib/local-db"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { initSentry } from "./lib/sentry"; @@ -151,6 +150,30 @@ app.on("open-url", async (event, url) => { } }); +export type QuitMode = "release" | "stop"; +let pendingQuitMode: QuitMode | null = null; +let isQuitting = false; + +/** Request the app to quit. + * - "release": keep services running (re-adoptable on next launch) + * - "stop": terminate all services before exit */ +export function requestQuit(mode: QuitMode): void { + pendingQuitMode = mode; + app.quit(); +} + +/** Set quit mode without triggering quit. + * Use when another API (e.g. autoUpdater.quitAndInstall) triggers quit internally. */ +export function prepareQuit(mode: QuitMode): void { + pendingQuitMode = mode; +} + +/** Exit the process immediately, bypassing before-quit. + * Services are left running for adoption on next launch. */ +export function exitImmediately(): void { + app.exit(0); +} + function getConfirmOnQuitSetting(): boolean { try { const row = localDb.select().from(settings).get(); @@ -161,61 +184,52 @@ function getConfirmOnQuitSetting(): boolean { } app.on("before-quit", async (event) => { - if (isExiting()) return; - - // Consume the intent so it doesn't persist across aborted quits - const intent = consumeIntent(); - - // Implicit quit (no explicit intent): optionally confirm before exiting - if (intent === null) { - const isDev = process.env.NODE_ENV === "development"; - if (!isDev && getConfirmOnQuitSetting()) { - event.preventDefault(); - - try { - const { response } = await dialog.showMessageBox({ - type: "question", - buttons: ["Quit", "Cancel"], - defaultId: 0, - cancelId: 1, - title: "Quit Superset", - message: "Are you sure you want to quit?", - }); - - if (response === 1) { - return; - } - } catch (error) { - console.error("[main] Quit confirmation dialog failed:", error); + if (isQuitting) return; + + // Consume the quit mode so it doesn't persist across aborted quits + const quitMode = pendingQuitMode; + pendingQuitMode = null; + + const isDev = process.env.NODE_ENV === "development"; + if (quitMode === null && !isDev && getConfirmOnQuitSetting()) { + event.preventDefault(); + + try { + const { response } = await dialog.showMessageBox({ + type: "question", + buttons: ["Quit", "Cancel"], + defaultId: 0, + cancelId: 1, + title: "Quit Superset", + message: "Are you sure you want to quit?", + }); + + if (response === 1) { + return; } + } catch (error) { + console.error("[main] Quit confirmation dialog failed:", error); } } - markExiting(); + isQuitting = true; const manager = getHostServiceManager(); - - if (intent === "exit_stop") { + if (quitMode === "stop") { manager.stopAll(); } else { manager.releaseAll(); } - disposeTray(); - - if (intent === "restart") { - app.relaunch(); - } - app.exit(0); }); process.on("uncaughtException", (error) => { - if (isExiting()) return; + if (isQuitting) return; console.error("[main] Uncaught exception:", error); }); process.on("unhandledRejection", (reason) => { - if (isExiting()) return; + if (isQuitting) return; console.error("[main] Unhandled rejection:", reason); }); diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 2b7f886d76b..b985196bad9 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { app, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import { env } from "main/env.main"; -import { prepareIntent } from "main/lib/lifecycle"; +import { prepareQuit } from "main/index"; import { prerelease } from "semver"; import { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { PLATFORM } from "shared/constants"; @@ -91,9 +91,8 @@ export function installUpdate(): void { emitStatus(AUTO_UPDATE_STATUS.IDLE); return; } - // quitAndInstall internally calls app.quit() — set intent beforehand - // so before-quit skips the confirm dialog and exits cleanly. - prepareIntent("exit_release"); + // quitAndInstall internally calls app.quit() — set mode beforehand + prepareQuit("release"); autoUpdater.quitAndInstall(false, true); } diff --git a/apps/desktop/src/main/lib/lifecycle.ts b/apps/desktop/src/main/lib/lifecycle.ts deleted file mode 100644 index 2fdd4c2c0f2..00000000000 --- a/apps/desktop/src/main/lib/lifecycle.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { app } from "electron"; - -/** - * Lifecycle intents for explicit quit actions. - * - * When no intent is set, implicit quit (Cmd+Q, Dock Quit) goes through - * the default before-quit path (confirm dialog, release services, exit). - * - * - exit_release: release host services (re-adoptable on next launch), full exit - * - exit_stop: stop host services, full exit - * - restart: release services, relaunch app, full exit - */ -export type LifecycleIntent = "exit_release" | "exit_stop" | "restart"; - -let pendingIntent: LifecycleIntent | null = null; -let exiting = false; - -/** Request a full exit with the given intent. Triggers app.quit(). */ -export function requestExit(intent: LifecycleIntent): void { - pendingIntent = intent; - app.quit(); -} - -/** - * Set an intent without triggering quit. - * Use when another API (e.g. autoUpdater.quitAndInstall) triggers quit internally. - */ -export function prepareIntent(intent: LifecycleIntent): void { - pendingIntent = intent; -} - -/** Consume and return the pending intent. Resets to null after reading. */ -export function consumeIntent(): LifecycleIntent | null { - const intent = pendingIntent; - pendingIntent = null; - return intent; -} - -export function markExiting(): void { - exiting = true; -} - -export function isExiting(): boolean { - return exiting; -} diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index e9e19cfde1f..0cc335788ba 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -7,8 +7,7 @@ import { nativeImage, Tray, } from "electron"; -import { focusMainWindow } from "main/index"; -import { requestExit } from "main/lib/lifecycle"; +import { focusMainWindow, requestQuit } from "main/index"; import { getHostServiceManager, type HostServiceStatus, @@ -240,18 +239,18 @@ function updateTrayMenu(): void { ...(hasActive ? [ { - label: "Quit Superset", - click: () => requestExit("exit_release"), + label: "Quit (Keep Services Running)", + click: () => requestQuit("release"), }, { label: "Quit & Stop Services", - click: () => requestExit("exit_stop"), + click: () => requestQuit("stop"), }, ] : [ { - label: "Quit Superset", - click: () => requestExit("exit_release"), + label: "Quit", + click: () => requestQuit("release"), }, ]), ]);