feat(updater): add a quit action so the macOS manual install can be completed - #491
Open
frarredondo wants to merge 7 commits into
Open
feat(updater): add a quit action so the macOS manual install can be completed#491frarredondo wants to merge 7 commits into
frarredondo wants to merge 7 commits into
Conversation
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.
parsakhaz
requested changes
Aug 23, 2026
Member
There was a problem hiding this comment.
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· disarmquitRequestedbefore 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, andhandleOpenTerminalWithCommandclearquitRequested, butopenDmgDownloadand 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.
- Evidence:
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
approved these changes
Aug 23, 2026
parsakhaz
left a comment
Member
There was a problem hiding this comment.
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.
Member
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


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 tocurl -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 thebefore-quitshutdown inmain/src/index.ts:1283, whosedestroyAllTerminals()(:1431) kills the PTY processes holdingpty.nodeinsidePane.app. A window close would not do it, becausewindow-all-closeddeliberately keeps the app alive on darwin (:1277).updater:quit-for-manual-installhandler, guarded to darwin, deferred one tick withsetImmediateso the reply reaches the renderer before teardown begins.renderMacUpdateActions(): that panel renders only in thedownloadedanderrorstates, while the ordinary macOS path returns toidlewith a message and would otherwise still dead-end.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 inmain/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 PaneandClosein the footer.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.3. Confirmation armed. Red
Quit nowwith "Quitting stops running agents.", andClosestill enabled.4. Non-macOS. No quit affordance,
Closeonly.All four states are also asserted by the new Playwright spec.
Test plan
pnpm typecheckclean across all four projectspnpm lintexit 0: oxlint including the 15 blocking anti-slop rules, eslint, advisory 0 findings, boundary conformance, knipmainvitest: 68 files, 625 tests (afterpnpm rebuild better-sqlite3-multiple-ciphers, per the Node/Electron ABI split)pnpm test:ci:minimalrun locally: 29 passed, including the new specmain/src/ipc/updater.test.ts: darwin quits, non-darwin refuses, and the quit is deferred rather than synchronoustests/update-dialog-quit.spec.ts: full click-through, arm then confirm, plus a non-macOS case asserting the button is absentPANE_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 firedthenDestroying all terminal panel processesthenCalling app.exit(0), with the process gone afterwardsThe 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 pinsnavigator.platformitself through a new opt-innavigatorPlatformoption intests/electronApiMock.ts, becauseisMac()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:
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.
Remaining for human review: all GitHub Actions checks passed. Optionally repeat the final quit against a packaged macOS build. Merge remains with parsa.