Skip to content

feat(updater): add a quit action so the macOS manual install can be completed - #491

Open
frarredondo wants to merge 7 commits into
dcouple:mainfrom
frarredondo:update-dialog-quit-option
Open

feat(updater): add a quit action so the macOS manual install can be completed#491
frarredondo wants to merge 7 commits into
dcouple:mainfrom
frarredondo:update-dialog-quit-option

Conversation

@frarredondo

@frarredondo frarredondo commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

On macOS the update path is manual, because unsigned builds cannot use electron-updater's quitAndInstall(): Gatekeeper quarantines the downloaded replacement. The dialog sends the user to curl -fsSL https://runpane.com/install.sh | sh, which downloads the DMG and opens it, then tells them to drag Pane.app into Applications and choose Replace. That drag cannot succeed while Pane is running, and the dialog offered only a Close button. Its own instructions named a step it gave no way to perform.

app.quit() is what frees the bundle. It runs the before-quit shutdown in main/src/index.ts:1283, whose destroyAllTerminals() (:1431) kills the PTY processes holding pty.node inside Pane.app. A window close would not do it, because window-all-closed deliberately keeps the app alive on darwin (:1277).

  • New updater:quit-for-manual-install handler, guarded to darwin, deferred one tick with setImmediate so the reply reaches the renderer before teardown begins.
  • Quit button in the dialog footer beside Close, which is unchanged. Gated to packaged macOS builds with an update available.
  • Footer rather than renderMacUpdateActions(): that panel renders only in the downloaded and error states, while the ordinary macOS path returns to idle with a message and would otherwise still dead-end.
  • First press arms a confirmation, second press quits, since quitting interrupts every running agent and the button sits next to Close. Any other action in the dialog disarms it.
  • A successful reply means the quit was requested, not that Pane is gone: the shutdown prompts about in-flight archive tasks and is abandoned if the user chooses to wait, so nothing latches the dialog into a quitting state.
  • The instruction copy branches on whether the button is actually rendered, so an unpackaged dev build in the error state never names a button that is not there.

Scoped channel name rather than a generic app:quit, to avoid handing the renderer an unconditional kill switch. updater: is absent from both daemon-owned channel lists in main/src/preload.ts, so this always quits the local machine and never routes to a remote daemon.

Screenshots

1. macOS, update available. Dialog as first opened, with Quit Pane and Close in the footer.

1-mac-update-available

2. After Update Pane. The state that previously dead-ended. Terminal has been opened, the message points at the button, and the footer now offers a way out.

2-mac-after-update-pane

3. Confirmation armed. Red Quit now with "Quitting stops running agents.", and Close still enabled.

3-mac-quit-armed

4. Non-macOS. No quit affordance, Close only.

4-windows-no-quit

All four states are also asserted by the new Playwright spec.

Test plan

  • pnpm typecheck clean across all four projects
  • pnpm lint exit 0: oxlint including the 15 blocking anti-slop rules, eslint, advisory 0 findings, boundary conformance, knip
  • main vitest: 68 files, 625 tests (after pnpm rebuild better-sqlite3-multiple-ciphers, per the Node/Electron ABI split)
  • frontend vitest: 27 files, 270 tests
  • pnpm test:ci:minimal run locally: 29 passed, including the new spec
  • New main/src/ipc/updater.test.ts: darwin quits, non-darwin refuses, and the quit is deferred rather than synchronous
  • New tests/update-dialog-quit.spec.ts: full click-through, arm then confirm, plus a non-macOS case asserting the button is absent
  • End to end on a real packaged build: built at 2.4.69 so the live 2.4.70 release is genuinely newer, launched with an isolated PANE_DIR. The real version check fired ([Version Checker] Update available on startup: 2.4.70) and the shutdown log confirms the quit path ran: before-quit fired then Destroying all terminal panel processes then Calling app.exit(0), with the process gone afterwards

The new spec is added to playwright.ci.minimal.config.ts. It is the only check that fails if the footer button is deleted: the two vitest suites cover the IPC handler and the gate predicate, and both still pass without the button. It pins navigator.platform itself through a new opt-in navigatorPlatform option in tests/electronApiMock.ts, because isMac() reads that property. Without pinning it, the macOS assertions would pass on a Mac and silently assert nothing on CI's Linux host.

Not addressed

The root cause of the manual path is that macOS builds are unsigned. This makes the documented workaround completable. Signing would remove this path entirely, including the dialog copy and this button.

Two deliberate open questions, both flagged during review:

  • The button appears in every macOS state with an update available, including before the user has obtained the installer. Gating it on evidence the manual path had started would re-create a dead-end for anyone who ran the install command in their own terminal, so I left it visible.
  • The armed confirmation has no explicit Cancel. Taking any other action disarms it, and Close remains available, so it is not a trap.

Automated QA update (head 06490222)

Status: local verification passed. Packaged macOS, confirmation/disarm behavior, main-process eligibility, and non-macOS absence are covered with mocked Electron APIs.

  • Typecheck and lint passed; advisory anti-slop findings: 0.
  • Frontend Vitest: 27 files, 270 tests passed.
  • Main Vitest: 68 files, 626 tests passed.
  • Playwright updater dialog: 2 tests passed.
  • Screenshots: update available, quit confirmation.

Remaining for human review: all GitHub Actions checks passed. Optionally repeat the final quit against a packaged macOS build. Merge remains with parsa.

On macOS the update path is manual because unsigned builds cannot use
quitAndInstall(): install.sh downloads the DMG and opens it, then asks the
user to drag Pane.app into Applications and choose Replace. That drag cannot
succeed while Pane is running, so the dialog documented a procedure it gave
no way to finish.

Add updater:quit-for-manual-install, which calls app.quit() so index.ts's
before-quit shutdown runs. Its destroyAllTerminals() is what releases the
bundle: the PTY processes hold pty.node inside Pane.app. A window close would
not do it, since window-all-closed deliberately keeps the app alive on darwin.

The button sits in the footer beside Close, which is unchanged, and is gated to
packaged macOS builds with an update available. Footer rather than
renderMacUpdateActions() because that panel only renders in the downloaded and
error states, while the ordinary macOS path returns to idle with a message.

A first press only arms the confirmation, since quitting interrupts running
agents. A successful reply means the quit was requested, not that Pane is gone
-- the shutdown still prompts about in-flight archive tasks and is abandoned if
the user waits -- so nothing latches the dialog into a quitting state.
Two defects from implementation review:

renderMacUpdateActions() renders in the error state on any mac build, gated on
isMac() alone with no isPackaged check. Its primary button set a message naming
"Quit Pane below" unconditionally, so an unpackaged mac dev build hitting an
updater error was told to use a button offerQuit correctly hides -- the same
instructions/actions desync this change exists to remove. Branch that copy on
offerQuit, as the other two macOS strings already do, and hoist offerQuit above
the handlers that now read it.

The live region overrode its content with the confirm prompt for as long as the
quit stayed armed, and quitRequested only clears when the dialog reopens. Every
later status change then went unannounced, and an unchanged aria-live node fires
nothing -- so arming the confirm silently cost a screen reader user all further
feedback. Announce the confirm once via role="alert" on the hint instead, leaving
the live region for ongoing status, and associate the hint with the button
through aria-describedby.
Three follow-ups from the second review pass, none blocking:

Use useId() for the confirm hint's id instead of a hardcoded string. The static
id was provably safe -- App.tsx renders exactly one UpdateDialog -- but every
other id relationship in frontend/src uses useId(), and matching that costs a
line.

Gate the remaining "use Quit Pane below" string on offerQuit. It was already
correct, but only via a three-hop reachability argument through the button's
render conditions; gating it deletes the argument rather than leaving the next
reader to reconstruct it.

Disarm the confirm when the user takes any other deliberate action. quitRequested
previously cleared only on reopen, so an arm could survive an isUpdateBusy window
and later sit one click away from quitting after an unrelated updater event.
It is the only check that fails if the macOS manual-install quit affordance is
removed: the two vitest suites cover the IPC handler and the gate predicate, but
both still pass with the footer button deleted. The spec pins navigator.platform
itself, so it asserts the same thing on CI's Linux host as on a Mac.
@frarredondo frarredondo changed the title fix(updater): add a quit action so the macOS manual install can be completed feat(updater): add a quit action so the macOS manual install can be completed Aug 23, 2026

@parsakhaz parsakhaz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: Request changes: the quit confirmation does not disarm after every other dialog action as the PR states.
Counts: Must Fix: 1 (security: 0) · Should Fix: 0 · pass 1/3

Must Fix

  • MF-1: Other dialog actions leave the destructive quit confirmation armed · frontend/src/components/UpdateDialog.tsx:335, frontend/src/components/UpdateDialog.tsx:396, frontend/src/components/UpdateDialog.tsx:730 · disarm quitRequested before download and external-link actions, and cover the transition in the UI spec · violates the PR statement “Any other action in the dialog disarms it.”
    • Evidence: handleStartUpdate, handleCopyUpdateCommand, and handleOpenTerminalWithCommand clear quitRequested, but openDmgDownload and the GitHub/release-link handlers do not. The existing Playwright test confirms only arm then confirm, so this state transition is untested.
    • Failure scenario: A user arms “Quit now,” clicks Download DMG or View on GitHub to continue the manual-install flow, returns to Pane, and a single click now quits every running agent even though their intervening action should have canceled confirmation.

Praise

  • The main-process handler is platform-gated and defers app.quit() until after the IPC reply, with focused tests proving both behaviors.
  • The renderer keeps Close available and preserves a usable dialog when the main-process shutdown prompt cancels quitting.

Checks run

  • git diff --check origin/main...HEAD: passed.
  • Frontend Vitest: 27 files, 270 tests passed.
  • Main Vitest: 68 files, 625 tests passed.
  • Security review: no auth, injection, secret, unsafe deserialization, or privilege-boundary defect found in the changed paths.

@parsakhaz parsakhaz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verdict: Approve: MF-1 is fixed and covered by the user-visible flow.
Counts: Must Fix: 0 (security: 0) · Should Fix: 0 · pass 2/3

Resolved findings

  • MF-1 fixed · frontend/src/components/UpdateDialog.tsx:335, tests/update-dialog-quit.spec.ts:61 · every update/download/link action now disarms the confirmation, and Playwright proves View on GitHub returns the button to the two-step “Quit Pane” state before quit can be requested.

Checks run

  • pnpm typecheck: passed.
  • pnpm lint: passed with zero advisory findings.
  • Frontend Vitest: 27 files, 270 tests passed.
  • Main Vitest: 68 files, 626 tests passed.
  • Playwright updater dialog flow: 2 tests passed, including disarm, confirm, main invocation, Close survival, and non-macOS absence.
  • Security re-review: the main handler now also rejects unpackaged macOS renderers, matching the renderer eligibility boundary.

@parsakhaz

parsakhaz commented Aug 23, 2026

Copy link
Copy Markdown
Member

Three-pass review complete

Head tested and pushed: 06490222b690b136a1c979d286eaa386ce00b09c

1. Review

  • Found one Must-Fix against the PR’s stated safety contract: external/download actions left “Quit now” armed.
  • Fixed it in 2ee52e73 by disarming on every other dialog action.
  • Added a Playwright regression assertion that View on GitHub returns the dialog to the two-step quit state.
  • Posted the requested-changes review, then approved after re-review. No security vulnerabilities remain.

2. Simplify

  • Committed 0f7c1088.
  • Replaced four repeated conditional copy branches with two grammar-specific instruction fragments.
  • Preserved the dedicated three-input eligibility helper because deleting it would reduce direct test coverage.
  • Before and after gates were identical or better: typecheck clean, lint clean, gate tests 4/4, IPC tests 2/2 before and 3/3 after the deep pass.

3. Refactor

  • Committed 06490222.
  • Made main, not only the renderer, enforce that manual-install quit is available only in packaged macOS builds.
  • Added the unpackaged-macOS rejection case, bringing main to 626 passing tests.

Verification

  • pnpm typecheck: passed.
  • pnpm lint: passed, including Oxlint, ESLint, boundary conformance, Knip, and 0 advisory findings.
  • Frontend Vitest: 27 files, 270 tests passed.
  • Main Vitest: 68 files, 626 tests passed.
  • Playwright tests/update-dialog-quit.spec.ts: 2 passed. It covers packaged macOS visibility, first-click arming, other-action disarm, confirmed IPC invocation, Close remaining usable, and non-macOS absence.
  • The standard Electron-backed Playwright server missed its 45-second cold-start timeout while many worktree jobs were active. An isolated Vite run initially captured the still-compiling blank frame, then the identical warmed run passed 2/2. No assertion failed on the passing run.
Current-head UI evidence
State Preview
Update available Packaged macOS shows Quit Pane beside Close.
Software Update dialog with Quit Pane and Close
Confirmation armed The destructive second step is explicit and Close stays available.
Software Update dialog with Quit now confirmation

Follow-ups

  • Consider a repository-wide typed updater IPC contract if other updater channels are refactored; a one-channel abstraction here would add more structure than it removes.
  • Remove the manual-install branch after Apple signing makes the normal updater path viable.

Left for parsa

  • All GitHub Actions checks passed on the pushed head.
  • Optionally repeat the final quit against a packaged macOS build; this pass used mocked Electron APIs for UI automation, while the PR already records an earlier real packaged-build run.
  • Review and merge personally. No merge was attempted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants