diff --git a/.ai/contexts/viewer-panel.md b/.ai/contexts/viewer-panel.md index 11d8f214b..9b557a08d 100644 --- a/.ai/contexts/viewer-panel.md +++ b/.ai/contexts/viewer-panel.md @@ -194,8 +194,8 @@ Each of these reads and clears `pendingTouchedOpen` before `destroyCurrentTab`, Nothing ends the window with unsaved file-panel edits without the user saying so. The scope is the Touched editor in the slot (a tab with an `absolutePath`) and every `touchedStashes` entry, in every session of `filePanelState`; the Memory and Work Files panels, MCP diff tabs and Changes buffers are not covered. -- **Main** (`unsaved-guard.js`, one guard, `attach(win)` per window, `beforeQuit(event, win)` for the app). `main.js`'s `before-quit` handler calls `beforeQuit` first: it prevents the quit, asks the renderer, and calls `app.quit()` again on a yes, so the cleanup that follows (PTYs killed, MCP servers, watchers) runs only once the quit is confirmed and a Cancel leaves the app intact. This covers every `app.quit()` caller (☰ Quit, the last window closing). `updater-install` asks first (`confirmQuit`), because electron-updater's `quitAndInstall()` starts the installer before it calls `app.quit()`: a Cancel must leave the installer unstarted, and a yes pre-approves the quit that follows. A Windows `session-end` approves the quit, so logoff and shutdown never wait on the dialog; `query-session-end` approves it for 60 s (`LOGOFF_CANCEL_MS`), so a logoff that is cancelled does not skip the question on every later quit. A window close, a quit and an install share one question while it is open; one that joins a `'reload'` question sends `unsaved-check-reason` (`id`, `'quit'`), and the renderer acts on the reason it holds when the user answers; a reload it has already approved then writes the app state at once. The renderer answers every upgrade with `unsaved-check-reason-done` (`id`): at once, unless it is writing for an approved reload, then once that write ends; main holds the joined quit until it, bounded by the check timeout and released at once if the renderer process is gone (`render-process-gone`, `destroyed`), since the answer may have left before the upgrade arrived. Until then the joined question stays the one in flight, so a close, quit or install arriving meanwhile joins it instead of asking again. Main skips the reload of a question a quit joined. The window's own `close` event is held the same way for a plain window close. The question is `unsaved-check` (`id`, `'quit'` or `'reload'`); the renderer's `unsaved-check-result` (`id`, `proceed`) approves it. A `will-prevent-unload` (the renderer's `beforeunload` veto, which a reload hits) asks with `'reload'`; on a yes the next unload is allowed once (`allowNextUnload`, answered with `preventDefault()`, which in Electron means "unload anyway") and `webContents.reload()` is called. Every close asks the renderer, even with nothing dirty; a main-side dirty flag would save that round trip and is not built. -- **Bounded.** The renderer acknowledges (`unsaved-check-ack`) as soon as it receives the check, before any dialog. The 2.5 s bound (`DEFAULT_TIMEOUT_MS`) covers only send to ack: no ack answers yes, so a hung renderer never keeps the app from quitting. The window is then closed the usual way (`win.close()`, or `app.quit()` for a quit or an update install), so a page that is only slow (still starting, or busy) closes as before, without the question, once it runs its `beforeunload`. A hung page never runs it, and that close would never finish: the guard destroys such a window when Electron reports it `unresponsive`, or when it is closed again, the guard's own `close()` or `app.quit()` aside. Electron reports a page `unresponsive` about 5 s after a close it has not completed, and cannot tell a hung page from a busy one: a page still busy about 5 s after that close, about 7.5 s after the click counting the 2.5 s bound, is destroyed, and its pending work is lost (the debounced working-set save among it). Only a page that recovers before then closes normally; a stall of 8–10 s that starts before the click (#526) does not. `e2e/hung-window.spec.js` measures it: the app exits about 7.5 s after the close it schedules. A page reported unresponsive earlier, and neither `responsive` nor acknowledging a check since, is destroyed when that close reaches it: right after `win.close()` for a window close, and for a quit or an update install only once the quit closes the window (its `close` event), so a quit still held by another `before-quit` handler (the ActivityWatch flush) is not cut short. A check the page acknowledged never destroys the window, however long the user takes. After the ack the guard waits for the answer without a limit, so a slow user or a slow save loses nothing, and answers yes only if the renderer process is gone (`render-process-gone`, `destroyed`) or a send fails. A late answer is ignored. +- **Main** (`unsaved-guard.js`, one guard, `attach(win)` per window, `beforeQuit(event, win)` for the app). `main.js`'s `before-quit` handler calls `beforeQuit` first: it prevents the quit, asks the renderer, and calls `app.quit()` again on a yes, so the cleanup that follows (PTYs killed, MCP servers, watchers) runs only once the quit is confirmed and a Cancel leaves the app intact. This covers every `app.quit()` caller (☰ Quit, the last window closing). `updater-install` asks first (`confirmQuit`), because electron-updater's `quitAndInstall()` starts the installer before it calls `app.quit()`: a Cancel must leave the installer unstarted, and a yes pre-approves the quit that follows. A Windows `session-end` approves the quit, so logoff and shutdown never wait on the dialog; `query-session-end` approves it for 60 s (`LOGOFF_CANCEL_MS`), so a logoff that is cancelled does not skip the question on every later quit. A window close, a quit and an install share one question while it is open. The window's own `close` event is held the same way for a plain window close. The question is `unsaved-check` (`id`, `'close'` for the window's own close, `'quit'` or `'reload'`; `'close'` also gets a "Close Switchboard?" confirm, see [window-frame](window-frame.md), "Closing the window"); a request that joins a pending question with a stronger reason upgrades it (`unsaved-check-reason`, `'reload'` < `'close'` < `'quit'`), and the renderer acts on the reason it holds when the user answers; a reload it has already approved then writes the app state at once. Each upgrade carries a token (`unsaved-check-reason`, `id`, reason, token), and the renderer answers it with `unsaved-check-reason-done` (`id`, token): at once, unless it is writing for an approved reload, then once that write ends; main holds the joined request until that upgrade's own report, bounded by the check timeout, released at once if the renderer process is gone (`render-process-gone`, `destroyed`), and not at all when the check ended unanswered, since the answer may have left before the upgrade arrived. Until the last upgrade joined to it has reported, the joined question stays the one in flight, so a close, quit or install arriving meanwhile joins it instead of asking again. Main skips the reload of a question a quit or a close joined. The renderer's `unsaved-check-result` (`id`, `proceed`) approves it. A `will-prevent-unload` (the renderer's `beforeunload` veto, which a reload hits) asks with `'reload'`; on a yes the next unload is allowed once (`allowNextUnload`, answered with `preventDefault()`, which in Electron means "unload anyway") and `webContents.reload()` is called. Every close asks the renderer, even with nothing dirty; a main-side dirty flag would save that round trip and is not built. +- **Bounded.** The renderer acknowledges (`unsaved-check-ack`) as soon as it receives the check, before any dialog. The 2.5 s bound (`DEFAULT_TIMEOUT_MS`) covers only send to ack, and only for `'quit'` and `'reload'`: no ack answers yes, so a hung renderer never keeps the app from quitting. A `'close'` check has no such bound: a busy page shows the close question once it is free, and a hung one is ended when Electron reports it unresponsive or the user closes it again (see [window-frame](window-frame.md), "A window that stops answering"). After an unanswered quit or update install the window is closed the usual way (`app.quit()`), so a page that is only slow closes as before, without the question, once it runs its `beforeunload`. A hung page never runs it, and that close would never finish: the guard destroys such a window when Electron reports it `unresponsive`, or when it is closed again, the guard's own `close()` or `app.quit()` aside. Electron reports a page `unresponsive` about 5 s after a close it has not completed, and cannot tell a hung page from a busy one: a page still busy about 5 s after that close, about 7.5 s after the quit counting the 2.5 s bound, is destroyed, and its pending work is lost (the debounced working-set save among it); a page that recovers before then closes normally. A page reported unresponsive earlier, and neither `responsive` nor acknowledging a check since, is destroyed once the quit closes the window (its `close` event), so a quit still held by another `before-quit` handler (the ActivityWatch flush) is not cut short. A check the page acknowledged never destroys the window, however long the user takes. After the ack the guard waits for the answer without a limit, so a slow user or a slow save loses nothing, and answers yes only if the renderer process is gone (`render-process-gone`, `destroyed`), a send fails, or a repeated close finds the page not answering (window-frame, "A window that stops answering"). The bounds pause while the page shows a native dialog ("A native dialog pauses the bounds" there). A late answer is ignored. - **Renderer** (`file-panel.js`). `askAboutUnsavedEdits()` lists the dirty tabs (`collectUnsavedFileTabs`) in the `#unsaved-edits-dialog` dialog, built on the add-project dialog's classes (already in the frameless no-drag list). Save writes every dirty entry: the editor in the slot through `handleChangesSave`, a stashed one through `saveFileForPanel(filePath, content, savedContent, {git, version})`, removed from its session once written. A save that fails (including a disk that moved) keeps the dialog open with the reason, and nothing is answered. Discard answers yes. Cancel and Escape answer no. A second request while the dialog is open gets the same dialog's answer. - **`beforeunload`.** The renderer vetoes an unload while an entry is dirty, unless `unloadApproved`, set for 10 s after the user answered yes. That is what stops a reload from the keyboard or devtools from slipping past, and what keeps the veto from asking a second time after an approved close. - **Closing a session** does not drop its file panel: `destroySession` leaves `filePanelState` alone, so the tab and the stashes, dirty or not, are still there when the session is opened again, and the quit check still sees them. Deleting a session leaves its state in memory too, so its edits are asked about at quit rather than lost. diff --git a/.ai/contexts/window-frame.md b/.ai/contexts/window-frame.md index db69e64a2..99343d5d8 100644 --- a/.ai/contexts/window-frame.md +++ b/.ai/contexts/window-frame.md @@ -233,3 +233,126 @@ controls back, and the restored window keeps its saved bounds. If the drag region were unreachable, the window manager's own bindings still move and resize the window: `Super`+drag on GNOME, `Alt`+`F7`/`Alt`+`F8`, or `Alt`+`Space` then Move/Size on Windows. + +## Closing the window + +The close button sits a few pixels from the strip's own controls and the +terminal's top-right corner, so a stray click there ended the app and every +session in it. A **window close** (the close button, `Alt`+`F4`, the window +manager's own close) now asks "Close Switchboard?" first; Cancel or `Escape` +leaves everything running. On macOS closing the window does not quit (the app +stays in the Dock), but the window's `closed` handler still stops every +session, so there the question reads "Close the window?" and says the app +stays in the Dock. The question is the in-page choice dialog +(`confirmWindowClose` in `file-panel.js`), not a native `confirm()`: Cancel +has the focus, so `Enter` keeps the app, and the page keeps running while it +is shown, which the probe below relies on. Only one choice dialog shows at a +time, so the close question is asked with `replace: true`: another one that is +open (the Archive folder dialog, say) is cancelled first, and a close is never +answered with a silent Cancel because something else was on screen. + +It rides on the unsaved-edits guard (`unsaved-guard.js`, see +[viewer-panel](viewer-panel.md), "Unsaved edits on quit, reload and close"): +the window's `close` event asks the renderer with the reason `'close'`, while +`before-quit` and the updater ask with `'quit'`. The renderer +(`file-panel.js`, the `onUnsavedCheck` handler) adds the confirm only for +`'close'`, and only when the unsaved-edits dialog was not shown: answering +that dialog with Save or Discard is already a decision to close. A **quit** +(☰ → Quit, which reads Exit on Windows and has `Ctrl`+`Q` only on Linux and +`Cmd`+`Q` on macOS; an update install; `SIGTERM`) is not asked about again. A +Windows logoff or shutdown approves the quit before the question +(`query-session-end`). + +A check for `'close'` has no time bound before the acknowledgement: a page that +is busy when the close arrives (stalled on a tab reveal) shows the question once +it is free, rather than closing without it. A page still starting has no +listener yet (`onUnsavedCheck` is registered by `initFilePanel`), so the check +it missed is sent again: registering the listener sends `unsaved-ready`, and +main sends the pending check again, with the reason it holds now, if it is not +acknowledged yet and that page is the one asked (the renderer ignores an id it +already has). A `'quit'` check keeps the 2.5 s bound, so a quit never waits for +ever on the page. + +A quit that arrives while the close question is pending (`SIGTERM`, a Linux or +macOS logout, an update install) joins the same check, and the guard upgrades +its reason: main sends `unsaved-check-reason` (`id`, `'quit'`), the +renderer closes the close question as a yes (or skips it if it is not shown +yet), and a check not acknowledged yet gets the quit's 2.5 s bound. The +unsaved-edits dialog stays: Cancel there still keeps the app, as for any quit. +A reason is only ever upgraded (`'reload'` < `'close'` < `'quit'`): a close +joining a reload question becomes a close, and a close joining a quit stays a +quit. + +### A window that stops answering + +A hung page would hold a close for good: nothing bounds a `'close'` check +before the acknowledgement, and after it the guard waits for the answer +without a limit, since a person may take their time. So the guard ends the +check as a yes and destroys the window (`win.destroy()`; a `close()` would wait +for the hung page's `beforeunload`) in two cases only: + +- **Electron reports the page `unresponsive`** while the check is not + acknowledged, or while a ping (below) waits for its answer. An acknowledged + check with no ping out ignores it: the user may be reading the question. + Electron does not report a page that hung before the close, since the guard + holds the close and nothing else is waiting on the page: in CI + (`e2e/hung-window.spec.js` with a single close, run 38070586412) the window + was still there 60 s later. So in practice a hung page is ended by the next + case. +- **The user closes the window again.** A close that arrives while the check + is still not acknowledged 2.5 s after it was sent (`DEFAULT_TIMEOUT_MS`; + `entry.overdue`) ends it; one that comes sooner, a double click on the close + button, does nothing. Once the page has acknowledged, a close that arrives + while the check is pending sends `unsaved-ping`; the preload answers it with + `unsaved-pong` on the renderer's main thread (the listener is added with + `onUnsavedCheck`), so a page that hangs cannot. Only a pong from the probed + window's own `webContents` counts, and it marks the page responsive again. + A ping that has gone unanswered for 1.5 s (`DEFAULT_PROBE_MS`) is overdue, + and the next close ends the check. Neither delay destroys anything by + itself: a page busy for 10 s survives unless the user closes it again. A + ping that cannot be sent ends the check at once. + +Only the window's own `close` event counts as a repeated close: a quit +(`before-quit`, `SIGTERM`) or an update install joining the check never pings, +so neither can be taken for the user insisting. + +A check the page never acknowledged for a quit ends at the 2.5 s bound; the +window is then closed the usual way and destroyed only once Electron reports +it unresponsive or it is closed again ([viewer-panel](viewer-panel.md), +"Bounded"). Electron reports a page about 5 s after a close it has not +completed and cannot tell a busy page from a hung one, so a page still busy +about 7.5 s after the quit (the 2.5 s bound, then those 5 s) is destroyed, and +its debounced working-set save is lost. The force waits for the quit to close +the window: a quit still held by another `before-quit` handler (the +ActivityWatch flush) is not cut short. `taskkill` without `/F` sends a close and gets the question like +any close; `taskkill /F` (and an installer that escalates to it) ends the +process without the quit cleanup, as it did before. + +Cancel on the close question or on the unsaved-edits dialog gives the focus +back to the element that had it (`returnFocus`), usually the terminal. + +### A native dialog pauses the bounds + +A native `alert()`, `confirm()` or `prompt()` blocks the page's main thread +until it is answered, and the preload's listeners run on that thread, so a page +showing one can neither acknowledge a check nor answer a ping. The check's own +path shows none (the close question and the unsaved-edits dialog are in-page, +and their saves report failures in the dialog), but other code does: the +sidebar's delete and hide confirms, the resume guard, the viewer's overwrite +and delete confirms. + +So the page reports them. `reportNativeDialogs` (`file-panel.js`, run by +`initFilePanel`) wraps `window.alert`, `window.confirm` and `window.prompt` +and sends `unsaved-dialog` `true` before the call and `false` after it +(`window.api.unsavedDialog`); a bare `confirm(…)` resolves to the wrapper too. +The `true` is sent before the thread blocks, so main has it before the dialog +shows. Main keeps the reporting `webContents` in a set. While it is there, a +repeated close sends no ping and ends nothing, `unresponsive` ends nothing, +and a quit check's 2.5 s bound is replaced by a 5-minute ceiling +(`DEFAULT_DIALOG_CEILING_MS`): the user can still answer the dialog, and a +quit (`SIGTERM`, a Linux or macOS logout) cannot wait for ever. When the +dialog closes, the bound that applies starts again from zero +(`entry.arm()`), and the page handles the queued check within milliseconds. A +crash or a navigation of that page clears its entry, so a dialog that never +reported its end cannot pause the bounds for good. A Windows logoff is not +affected, it approves the quit first. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e2e35d2a..bd4b03ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### Changed - Markdown previews now show a file's frontmatter as a readable header above its content, including its name, description and metadata. (#593) +- Closing the window (the close button, `Alt`+`F4`) asks for confirmation first, with Cancel selected; a window that is busy at that moment asks once it is free. Quitting from the ☰ menu (Quit, or Exit on Windows) does not ask again, and a quit from outside (a logout, an update install) is not stopped by the question. On macOS it asks "Close the window?": Switchboard stays in the Dock, but its sessions stop. If the window has stopped responding, a single close waits, and closing it again a few seconds later ends it; never while one of its own confirmation boxes waits for an answer. A window closed while it is still starting asks once it has loaded. (#478) ### Fixed - On Windows and Linux, automation triggers follow a running conversation when its id changes, except in WSL or the Linux sandbox. (#579) diff --git a/docs/e2e.md b/docs/e2e.md index 730ae1bf9..5f26e3ab4 100644 --- a/docs/e2e.md +++ b/docs/e2e.md @@ -49,7 +49,9 @@ here. | The shell opened with no tab fills the panel and is not a sidebar row | `panel.spec.js` | the `.shell-only` layout, and `buildProjectsFromCache` skipping the panel shell | | Changes on a project with no git work tree says so, with no git output | `panel.spec.js` | the not-a-repository note instead of git's raw output | | Refresh beside Stop redraws a plain terminal and restores its fitted size | `terminal-refresh.spec.js` | visible header geometry, one refresh IPC, actual PTY size events, and final fitted size | -| Closing a window whose page hangs still ends the app | `hung-window.spec.js` | the 2.5 s bound on an unacknowledged check, the window destroyed once Electron reports the page unresponsive, and exit code 0 | +| A window close asks first: Cancel keeps the window and its terminal, Close exits | `close-window.spec.js` | the guard holding the real `close` event, the in-page question, a terminal that still runs commands after Cancel, the exit (code 0) after Close | +| Quit from the menu exits without asking | `close-window.spec.js` | the `quit` role reaching `before-quit`, the guard not asking a quit a second time, and exit code 0 | +| Closing a window whose page hangs, then closing it again, still ends the app | `hung-window.spec.js` | the close question never shown, the second close after the 2.5 s bound destroying the window, and exit code 0 | The issue's fifth journey expected the Changes control to disappear. #310 made it unconditional, so the journey checks what the panel says instead (see @@ -105,7 +107,7 @@ and the absence of new Unicode directories in the checkout after teardown. `taskkill /T /F` on that pid, which kills the process and its descendants. Then it deletes the temporary `HOME`. If the app exits within the 10 s, no - kill is sent. A journey may end the app itself (`hung-window.spec.js` does); + kill is sent. A journey may end the app itself (`hung-window.spec.js` and `close-window.spec.js` do); the fixture then skips the trace, the screenshot and the close, and only deletes the temporary `HOME`. diff --git a/e2e/close-window.spec.js b/e2e/close-window.spec.js new file mode 100644 index 000000000..2af8e9e59 --- /dev/null +++ b/e2e/close-window.spec.js @@ -0,0 +1,68 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { test, expect, makeRepo, openPlainTerminal } = require('./fixtures'); + +const EXIT_TIMEOUT_MS = 20_000; + +function exited(proc) { + return proc.exitCode !== null || proc.signalCode !== null; +} + +async function closeWindow(app) { + await app.evaluate(({ BrowserWindow }) => { BrowserWindow.getAllWindows()[0].close(); }); +} + +test('a window close asks first: Cancel keeps the window and its terminal, Close exits', async ({ home, env, launch }) => { + const repo = makeRepo(home, env, { 'readme.txt': 'fixture\n' }); + const { app, page } = await launch(); + await openPlainTerminal(page); + const sessionId = await page.evaluate(() => activeSessionId); + + await closeWindow(app); + const question = page.locator('.choice-dialog'); + await expect(question).toBeVisible(); + await question.locator('.choice-dialog-cancel').click(); + await expect(question).toHaveCount(0); + + expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1); + const live = await page.evaluate(async () => (await window.api.getActiveSessions()).map((e) => e.sessionId)); + expect(live).toContain(sessionId); + const marker = path.join(repo, 'still-running.txt'); + await page.evaluate(([id, file]) => window.api.sendInput(id, `echo ok > "${file}"\r`), [sessionId, marker]); + await expect.poll(() => fs.existsSync(marker)).toBe(true); + + const proc = app.process(); + await closeWindow(app); + await expect(question).toBeVisible(); + await question.locator('.choice-dialog-confirm').click(); + await expect.poll(() => exited(proc), { timeout: EXIT_TIMEOUT_MS }).toBe(true); + expect(proc.signalCode).toBe(null); + expect(proc.exitCode).toBe(0); +}); + +test('Quit from the menu exits without asking', async ({ home, env, launch }) => { + makeRepo(home, env, { 'readme.txt': 'fixture\n' }); + const { app, page } = await launch(); + await openPlainTerminal(page); + const proc = app.process(); + const found = await app.evaluate(({ Menu }) => { + const find = (items) => { + for (const item of items) { + if (item.role === 'quit') return item; + const inner = item.submenu && find(item.submenu.items); + if (inner) return inner; + } + return null; + }; + const quit = find(Menu.getApplicationMenu().items); + if (!quit) return false; + setImmediate(() => quit.click()); + return true; + }); + expect(found).toBe(true); + await expect.poll(() => exited(proc), { timeout: EXIT_TIMEOUT_MS }).toBe(true); + expect(proc.signalCode).toBe(null); + expect(proc.exitCode).toBe(0); +}); diff --git a/e2e/hung-window.spec.js b/e2e/hung-window.spec.js index 66925ec03..d8df68be5 100644 --- a/e2e/hung-window.spec.js +++ b/e2e/hung-window.spec.js @@ -4,12 +4,16 @@ const { test, expect, makeRepo } = require('./fixtures'); const EXIT_TIMEOUT_MS = 60_000; -test('closing a window whose page hangs still ends the app', async ({ home, env, launch }) => { +test('closing a window whose page hangs, then closing it again, still ends the app', async ({ home, env, launch }) => { test.setTimeout(150_000); makeRepo(home, env, { 'readme.txt': 'fixture\n' }); const { app, page } = await launch(); const proc = app.process(); - await app.evaluate(({ BrowserWindow }) => { setTimeout(() => BrowserWindow.getAllWindows()[0].close(), 2000); }); + await app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + setTimeout(() => win.close(), 2000); + setTimeout(() => { if (!win.isDestroyed()) win.close(); }, 6000); + }); await page.evaluate(() => { setTimeout(() => { for (;;) Date.now(); }, 500); }); await expect.poll(() => proc.exitCode !== null || proc.signalCode !== null, { timeout: EXIT_TIMEOUT_MS }).toBe(true); expect(proc.signalCode).toBe(null); diff --git a/preload.js b/preload.js index fb62fa74e..6f718d145 100644 --- a/preload.js +++ b/preload.js @@ -162,13 +162,17 @@ contextBridge.exposeInMainWorld('api', { }, onUnsavedCheck: (callback) => { ipcRenderer.on('unsaved-check', (_event, id, reason) => callback(id, reason)); + // see .ai/contexts/window-frame.md ("A window that stops answering") + ipcRenderer.on('unsaved-ping', (_event, token) => ipcRenderer.send('unsaved-pong', token)); + ipcRenderer.send('unsaved-ready'); }, onUnsavedCheckReason: (callback) => { - ipcRenderer.on('unsaved-check-reason', (_event, id, reason) => callback(id, reason)); + ipcRenderer.on('unsaved-check-reason', (_event, id, reason, token) => callback(id, reason, token)); }, unsavedCheckAck: (id) => ipcRenderer.send('unsaved-check-ack', id), + unsavedDialog: (open) => ipcRenderer.send('unsaved-dialog', open === true), unsavedCheckResult: (id, proceed) => ipcRenderer.send('unsaved-check-result', id, proceed), - unsavedCheckReasonDone: (id) => ipcRenderer.send('unsaved-check-reason-done', id), + unsavedCheckReasonDone: (id, token) => ipcRenderer.send('unsaved-check-reason-done', id, token), onExitFlush: (callback) => { ipcRenderer.on('exit-flush', () => callback()); }, diff --git a/public/choice-dialog.js b/public/choice-dialog.js index 8df9ad77d..dc694ce91 100644 --- a/public/choice-dialog.js +++ b/public/choice-dialog.js @@ -8,9 +8,13 @@ function writeRememberedChoice(rememberKey, checked) { try { localStorage.setItem(rememberKey, checked ? '1' : '0'); } catch {} } +let closeOpenChoiceDialog = null; + /** * A modal with optional checkboxes. Resolves to `{ [choice.id]: boolean }` on - * confirm, null on cancel, and null at once while another one is open. + * confirm, null on cancel, and null at once while another one is open, unless + * `replace` cancels the open one and shows this one. `closeWith`, a promise, + * closes the dialog with the value it resolves to. */ function showChoiceDialog({ title, @@ -21,8 +25,14 @@ function showChoiceDialog({ initialFocus = 'confirm', danger = false, returnFocus, + replace = false, + closeWith = null, } = {}) { - if (document.querySelector('.choice-dialog-overlay')) return Promise.resolve(null); + if (document.querySelector('.choice-dialog-overlay')) { + if (!replace || !closeOpenChoiceDialog) return Promise.resolve(null); + closeOpenChoiceDialog(null); + if (returnFocus && returnFocus.isConnected === false) returnFocus = document.activeElement; + } return new Promise((resolve) => { const overlay = document.createElement('div'); @@ -87,7 +97,10 @@ function showChoiceDialog({ const buttons = [closeBtn, cancelBtn, confirmBtn]; + let closed = false; function close(value) { + if (closed) return; + closed = true; overlay.remove(); document.removeEventListener('keydown', onKey, true); if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus(); @@ -129,6 +142,8 @@ function showChoiceDialog({ overlay.addEventListener('click', (event) => { if (event.target === overlay) close(null); }); document.body.appendChild(overlay); + closeOpenChoiceDialog = close; + if (closeWith) closeWith.then(close, () => {}); document.addEventListener('keydown', onKey, true); (initialFocus === 'cancel' ? cancelBtn : confirmBtn).focus(); }); diff --git a/public/file-panel.js b/public/file-panel.js index d06604e9f..29ab885bf 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -149,27 +149,43 @@ function initFilePanel() { event.preventDefault(); event.returnValue = false; }); + if (window.api.unsavedDialog) reportNativeDialogs(); if (window.api.onUnsavedCheck) { const unsavedCheckReasons = new Map(); + const reasonChanged = new Map(); const answeredReloads = new Set(); if (window.api.onUnsavedCheckReason) { - window.api.onUnsavedCheckReason((id, reason) => { - const done = () => { if (window.api.unsavedCheckReasonDone) window.api.unsavedCheckReasonDone(id); }; - if (unsavedCheckReasons.has(id)) unsavedCheckReasons.set(id, reason); - else if (answeredReloads.delete(id) && typeof window.flushStateForExit === 'function') { - window.flushStateForExit().catch((err) => console.error('[exit-flush]', err)).finally(done); + window.api.onUnsavedCheckReason((id, reason, token) => { + const done = () => { if (window.api.unsavedCheckReasonDone) window.api.unsavedCheckReasonDone(id, token); }; + if (!unsavedCheckReasons.has(id)) { + if (answeredReloads.delete(id) && typeof window.flushStateForExit === 'function') { + window.flushStateForExit().catch((err) => console.error('[exit-flush]', err)).finally(done); + return; + } + done(); return; } + unsavedCheckReasons.set(id, reason); + const notify = reasonChanged.get(id); + if (notify) notify(reason); done(); }); } window.api.onUnsavedCheck(async (id, reason) => { + if (unsavedCheckReasons.has(id)) return; unsavedCheckReasons.set(id, reason); window.api.unsavedCheckAck(id); let proceed = true; - try { proceed = await askAboutUnsavedEdits(); } catch (err) { console.error('[unsaved-check]', err); } + try { + const asksAboutEdits = collectUnsavedFileTabs().length > 0; + proceed = await askAboutUnsavedEdits(); + // see .ai/contexts/window-frame.md ("Closing the window") + if (proceed && unsavedCheckReasons.get(id) === 'close' && !asksAboutEdits) { + proceed = await confirmWindowClose(new Promise((resolve) => reasonChanged.set(id, resolve))); + } + } catch (err) { console.error('[unsaved-check]', err); } const finalReason = unsavedCheckReasons.get(id); - unsavedCheckReasons.delete(id); + reasonChanged.delete(id); // see docs/session-restore.md ("Closing the app") if (proceed && finalReason !== 'reload' && typeof window.flushStateForExit === 'function') { const bound = new Promise((resolve) => setTimeout(resolve, EXIT_FLUSH_BOUND_MS)); @@ -177,6 +193,7 @@ function initFilePanel() { } if (proceed && finalReason === 'reload') answeredReloads.add(id); window.api.unsavedCheckResult(id, proceed); + unsavedCheckReasons.delete(id); }); } @@ -640,6 +657,7 @@ async function saveUnsavedFileTab(tab) { } function showUnsavedEditsDialog(tabs) { + const returnFocus = document.activeElement; return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'add-project-overlay'; @@ -692,6 +710,7 @@ function showUnsavedEditsDialog(tabs) { function finish(proceed) { overlay.remove(); document.removeEventListener('keydown', onKey); + if (!proceed && returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus(); resolve(proceed); } function onKey(e) { @@ -721,6 +740,38 @@ function showUnsavedEditsDialog(tabs) { }); } +// see .ai/contexts/window-frame.md ("A native dialog pauses the bounds") +function reportNativeDialogs() { + for (const name of ['alert', 'confirm', 'prompt']) { + const native = window[name]; + if (typeof native !== 'function') continue; + window[name] = function (...args) { + window.api.unsavedDialog(true); + try { + return native.apply(window, args); + } finally { + window.api.unsavedDialog(false); + } + }; + } +} + +// see .ai/contexts/window-frame.md ("Closing the window") +async function confirmWindowClose(quitting = null) { + const mac = window.api.platform === 'darwin'; + const result = await showChoiceDialog({ + title: mac ? 'Close the window?' : 'Close Switchboard?', + message: mac ? 'Every session running in it stops; Switchboard stays in the Dock.' : 'Every session running in it stops.', + confirmLabel: 'Close', + initialFocus: 'cancel', + danger: true, + returnFocus: document.activeElement, + replace: true, + closeWith: quitting && quitting.then(() => ({})), + }); + return result !== null; +} + function askAboutUnsavedEdits() { if (unsavedPrompt) return unsavedPrompt; const tabs = collectUnsavedFileTabs(); diff --git a/test/dom-choice-dialog.test.js b/test/dom-choice-dialog.test.js index 4a853285c..a6061913d 100644 --- a/test/dom-choice-dialog.test.js +++ b/test/dom-choice-dialog.test.js @@ -253,3 +253,28 @@ test('choice dialog: Enter outside the dialog does nothing', async () => { assert.equal(await pending, null); } finally { ctx.destroy(); } }); + +test('choice dialog: closeWith closes it with its value, and does nothing once the user has answered', async () => { + const ctx = setupSidebarDom(); + try { + let settle; + const pending = open(ctx, { closeWith: new Promise((r) => { settle = r; }) }); + settle({ a: true }); + assert.deepEqual(await pending, { a: true }); + assert.equal(overlay(ctx), null); + + const before = ctx.document.createElement('button'); + ctx.document.body.appendChild(before); + before.focus(); + let late; + const answered = open(ctx, { closeWith: new Promise((r) => { late = r; }), returnFocus: before }); + cancelBtn(ctx).click(); + assert.equal(await answered, null); + const elsewhere = ctx.document.createElement('button'); + ctx.document.body.appendChild(elsewhere); + elsewhere.focus(); + late({}); + await new Promise((r) => setTimeout(r)); + assert.equal(ctx.document.activeElement, elsewhere, 'the focus is not taken back a second time'); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-file-panel-unsaved-guard.test.js b/test/dom-file-panel-unsaved-guard.test.js index 398b4419b..a6e86edcb 100644 --- a/test/dom-file-panel-unsaved-guard.test.js +++ b/test/dom-file-panel-unsaved-guard.test.js @@ -10,6 +10,8 @@ const path = require('node:path'); const vm = require('node:vm'); const { JSDOM } = require('jsdom'); const { registerPanelTerminals } = require('./terminal-manager-harness'); +const { EventEmitter } = require('node:events'); +const { createUnsavedGuard } = require('../unsaved-guard'); const PUBLIC_DIR = path.join(__dirname, '..', 'public'); @@ -43,7 +45,7 @@ function setup() { const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); const { window } = dom; const disk = new Map(); - const calls = { openFile: null, check: null, acks: [], answers: [], saves: [], confirms: [], reasonDone: [] }; + const calls = { openFile: null, check: null, reason: null, acks: [], answers: [], saves: [], confirms: [], reasonDone: [], reasonDoneTokens: [] }; let editor = null; window.api = new Proxy({ @@ -52,7 +54,7 @@ function setup() { onUnsavedCheckReason: (cb) => { calls.reason = cb; }, unsavedCheckAck: (id) => { calls.acks.push({ id, at: calls.answers.length, dialog: !!window.document.getElementById('unsaved-edits-dialog') }); }, unsavedCheckResult: (id, proceed) => { calls.answers.push({ id, proceed }); }, - unsavedCheckReasonDone: (id) => { calls.reasonDone.push(id); }, + unsavedCheckReasonDone: (id, token) => { calls.reasonDone.push(id); calls.reasonDoneTokens.push(token); }, watchFile: () => Promise.resolve({ ok: true }), unwatchFile: () => Promise.resolve({ ok: true }), readFileForPanel: (p) => Promise.resolve(disk.has(p) ? { ok: true, git: false, original: disk.get(p), current: disk.get(p) } : { ok: false, code: 'ENOENT', error: 'ENOENT' }), @@ -69,7 +71,7 @@ function setup() { return () => Promise.resolve({ ok: true }); }, }); - window.confirm = (msg) => { calls.confirms.push(msg); return false; }; + window.confirm = (msg) => { calls.confirms.push(msg); return calls.onConfirm ? calls.onConfirm(msg) : false; }; window.createEditableViewer = (parent, content) => { editor = fakeEditor(content); editor.dom = window.document.createElement('div'); @@ -86,7 +88,7 @@ function setup() { return el; }; - for (const f of ['viewer-toolbar.js', 'viewer-panel.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'file-panel.js', 'touched-files-view.js']) { + for (const f of ['viewer-toolbar.js', 'viewer-panel.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'choice-dialog.js', 'file-panel.js', 'touched-files-view.js']) { vm.runInContext(fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8'), dom.getInternalVMContext(), { filename: path.join(PUBLIC_DIR, f) }); } window.openSessions = new Map(); @@ -100,6 +102,48 @@ function setup() { } const flush = () => new Promise((r) => setTimeout(r, 5)); + +function wireGuard(ctx) { + const ipcMain = new EventEmitter(); + const timers = []; + const sent = []; + const wc = new EventEmitter(); + Object.assign(wc, { + send: (channel, ...args) => { + sent.push({ channel, args }); + if (channel === 'unsaved-check') setTimeout(() => ctx.calls.check(...args)); + }, + isDestroyed: () => false, + isCrashed: () => false, + }); + const win = new EventEmitter(); + Object.assign(win, { + webContents: wc, + isDestroyed: () => win.destroys > 0, + close: () => { win.closes += 1; }, + destroy: () => { win.destroys += 1; }, + closes: 0, + destroys: 0, + }); + const guard = createUnsavedGuard({ + ipcMain, + probeMs: 1500, + setTimeoutFn: (fn, ms) => { const t = { fn, ms, cleared: false }; timers.push(t); return t; }, + clearTimeoutFn: (t) => { t.cleared = true; }, + }); + guard.attach(win); + Object.assign(ctx.window.api, { + unsavedCheckAck: (id) => ipcMain.emit('unsaved-check-ack', {}, id), + unsavedCheckResult: (id, proceed) => ipcMain.emit('unsaved-check-result', {}, id, proceed), + unsavedCheckReasonDone: (id, token) => ipcMain.emit('unsaved-check-reason-done', {}, id, token), + unsavedDialog: (open) => ipcMain.emit('unsaved-dialog', { sender: wc }, open), + }); + const close = () => win.emit('close', { preventDefault() {} }); + const pings = () => sent.filter((m) => m.channel === 'unsaved-ping'); + const armed = (ms) => timers.filter((x) => x.ms === ms && !x.cleared); + const pong = (token) => ipcMain.emit('unsaved-pong', { sender: wc }, token); + return { win, close, pings, armed, pong }; +} const A = '/repo/a.md'; const B = '/repo/b.md'; const C = '/repo/c.md'; @@ -314,6 +358,24 @@ test('a quit that joins a reload check the page has already answered still write } finally { ctx.destroy(); } }); +test('the page reports each upgrade with the token it came with', async () => { + const ctx = setup(); + try { + let finish = null; + ctx.window.flushStateForExit = () => new Promise((resolve) => { finish = resolve; }); + ctx.calls.check(6, 'reload'); + await flush(); + ctx.calls.reason(6, 'close', 41); + await flush(); + ctx.calls.reason(6, 'quit', 42); + await flush(); + assert.deepEqual(ctx.calls.reasonDoneTokens, [42], 'the second upgrade has no save of its own to wait for'); + finish(); + await flush(); + assert.deepEqual(ctx.calls.reasonDoneTokens, [42, 41], 'the first is reported once its save lands'); + } finally { ctx.destroy(); } +}); + test('the page tells main when the save a late upgrade asked for is done, and at once when it has none to do', async () => { const ctx = setup(); try { @@ -351,6 +413,25 @@ test('the page tells main when the save a late upgrade asked for is done, and at } finally { ctx.destroy(); } }); +test('a check sent again while the page writes the app state for it is not asked or written a second time', async () => { + const ctx = setup(); + try { + let flushes = 0; + let finish = null; + ctx.window.flushStateForExit = () => { flushes++; return new Promise((resolve) => { finish = resolve; }); }; + ctx.calls.check(10, 'quit'); + await flush(); + assert.equal(flushes, 1); + ctx.calls.check(10, 'quit'); + await flush(); + assert.equal(flushes, 1, 'one write for one check'); + assert.equal(ctx.calls.acks.length, 1); + finish(); + await flush(); + assert.deepEqual(ctx.calls.answers, [{ id: 10, proceed: true }]); + } finally { ctx.destroy(); } +}); + test('a cancelled close does not flush', async () => { const ctx = setup(); try { @@ -364,3 +445,211 @@ test('a cancelled close does not flush', async () => { assert.equal(flushed, 0); } finally { ctx.destroy(); } }); + +// see .ai/contexts/window-frame.md ("Closing the window") +test('a window close with nothing unsaved asks to close Switchboard; Cancel, Enter and Escape keep it open', async () => { + const ctx = setup(); + const { document } = ctx.window; + const closeDialog = () => document.querySelector('.choice-dialog'); + const key = (k) => document.activeElement.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); + try { + ctx.calls.check(1, 'close'); + await flush(); + assert.equal(closeDialog().querySelector('#choice-dialog-title').textContent, 'Close Switchboard?'); + assert.equal(document.activeElement, closeDialog().querySelector('.choice-dialog-cancel'), 'Cancel has the focus'); + key('Enter'); + await flush(); + assert.equal(closeDialog(), null); + assert.deepEqual(ctx.calls.answers, [{ id: 1, proceed: false }], 'Enter on the focused Cancel keeps the app'); + + ctx.calls.check(2, 'close'); + await flush(); + key('Escape'); + await flush(); + assert.deepEqual(ctx.calls.answers[1], { id: 2, proceed: false }); + + ctx.calls.check(3, 'close'); + await flush(); + closeDialog().querySelector('.choice-dialog-confirm').click(); + await flush(); + assert.deepEqual(ctx.calls.answers[2], { id: 3, proceed: true }); + assert.deepEqual(ctx.calls.confirms, [], 'no native dialog, which blocks the page'); + } finally { ctx.destroy(); } +}); + +test('a window close while another choice dialog is open cancels that one and asks to close Switchboard', async () => { + const ctx = setup(); + const { document } = ctx.window; + try { + const origin = document.createElement('button'); + document.body.appendChild(origin); + origin.focus(); + let other; + ctx.window.showChoiceDialog({ title: 'Archive folder?', confirmLabel: 'Archive', returnFocus: origin }).then((v) => { other = v; }); + await flush(); + ctx.calls.check(1, 'close'); + await flush(); + assert.equal(other, null, 'the other dialog ends as a cancel, so nothing it offered is done'); + assert.equal(document.querySelectorAll('.choice-dialog').length, 1); + assert.equal(document.querySelector('#choice-dialog-title').textContent, 'Close Switchboard?'); + assert.deepEqual(ctx.calls.answers, [], 'the close is not answered before the user decides'); + document.querySelector('.choice-dialog-cancel').click(); + await flush(); + assert.deepEqual(ctx.calls.answers, [{ id: 1, proceed: false }]); + assert.equal(document.activeElement, origin, 'Cancel gives the focus back to where it was before either dialog'); + ctx.calls.check(2, 'close'); + await flush(); + document.querySelector('.choice-dialog-confirm').click(); + await flush(); + assert.deepEqual(ctx.calls.answers[1], { id: 2, proceed: true }); + } finally { ctx.destroy(); } +}); + +test('a choice dialog that does not ask to replace still yields to one already open', async () => { + const ctx = setup(); + try { + ctx.window.showChoiceDialog({ title: 'first' }); + const second = await ctx.window.showChoiceDialog({ title: 'second' }); + assert.equal(second, null); + assert.equal(ctx.window.document.querySelector('#choice-dialog-title').textContent, 'first'); + } finally { ctx.destroy(); } +}); + +test('a quit (menu, updater) is not asked about a second time', async () => { + const ctx = setup(); + try { + ctx.calls.check(1, 'quit'); + await flush(); + assert.deepEqual(ctx.calls.confirms, []); + assert.deepEqual(ctx.calls.answers, [{ id: 1, proceed: true }]); + } finally { ctx.destroy(); } +}); + +test('a window close over unsaved edits asks once: Discard closes without a second question', async () => { + const ctx = setup(); + try { + await dirtyTab(ctx, 's1', A); + ctx.calls.check(3, 'close'); + await flush(); + assert.ok(dialog(ctx)); + button(ctx, 'unsaved-discard').click(); + await flush(); + assert.equal(ctx.window.document.querySelector('.choice-dialog'), null); + assert.deepEqual(ctx.calls.answers, [{ id: 3, proceed: true }]); + } finally { ctx.destroy(); } +}); + +test('a native dialog the page shows is reported around it, and the shipped guard does not end a close while it waits', async () => { + const ctx = setup(); + const main = wireGuard(ctx); + try { + await dirtyTab(ctx, 's1', A); + let during = null; + ctx.calls.onConfirm = () => { + main.close(); + main.close(); + main.close(); + main.win.emit('unresponsive'); + during = { pings: main.pings().length, destroys: main.win.destroys }; + return false; + }; + ctx.window.confirm('Delete "x"?'); + assert.deepEqual(during, { pings: 0, destroys: 0 }, 'a close that arrives behind a native dialog waits for the user to answer it'); + await flush(); + assert.ok(dialog(ctx), 'the page answers once the dialog is gone'); + + ctx.calls.onConfirm = () => { + main.close(); + main.win.emit('unresponsive'); + during = { pings: main.pings().length, destroys: main.win.destroys }; + return false; + }; + ctx.window.confirm('Discard?'); + assert.deepEqual(during, { pings: 0, destroys: 0 }, 'nor does a close while the question is up'); + button(ctx, 'unsaved-save').click(); + await flush(); + assert.equal(ctx.disk.get(A), 'x0\nmine'); + assert.equal(main.win.destroys, 0); + assert.equal(main.win.closes, 1, 'closed by the answer, after the save'); + } finally { ctx.destroy(); } +}); + +test('Cancel gives the focus back to where it was, for the close question and for the unsaved-edits dialog', async () => { + const ctx = setup(); + const { document } = ctx.window; + try { + const field = document.createElement('textarea'); + document.body.appendChild(field); + field.focus(); + ctx.calls.check(1, 'close'); + await flush(); + assert.notEqual(document.activeElement, field); + document.querySelector('.choice-dialog-cancel').click(); + await flush(); + assert.equal(document.activeElement, field, 'after the close question'); + + await dirtyTab(ctx, 's1', A); + field.focus(); + ctx.calls.check(2, 'quit'); + await flush(); + assert.notEqual(document.activeElement, field); + button(ctx, 'unsaved-cancel').click(); + await flush(); + assert.equal(document.activeElement, field, 'after the unsaved-edits dialog'); + assert.deepEqual(ctx.calls.answers.map((a) => a.proceed), [false, false]); + } finally { ctx.destroy(); } +}); + +test('a quit that joins the close question closes it as a yes; one before it shows skips it', async () => { + const ctx = setup(); + const { document } = ctx.window; + try { + ctx.calls.check(1, 'close'); + await flush(); + assert.ok(document.querySelector('.choice-dialog')); + ctx.calls.reason(1, 'quit'); + await flush(); + assert.equal(document.querySelector('.choice-dialog'), null, 'SIGTERM or a logout is not cancelled by the close question'); + assert.deepEqual(ctx.calls.answers, [{ id: 1, proceed: true }]); + + ctx.calls.check(3, 'close'); + ctx.calls.reason(3, 'quit'); + await flush(); + assert.equal(document.querySelector('.choice-dialog'), null, 'never shown'); + assert.deepEqual(ctx.calls.answers[1], { id: 3, proceed: true }); + + await dirtyTab(ctx, 's1', A); + ctx.calls.check(2, 'close'); + await flush(); + ctx.calls.reason(2, 'quit'); + button(ctx, 'unsaved-discard').click(); + await flush(); + assert.equal(document.querySelector('.choice-dialog'), null); + assert.deepEqual(ctx.calls.answers[2], { id: 2, proceed: true }); + + ctx.calls.reason(9, 'quit'); + assert.equal(ctx.calls.answers.length, 3, 'a reason for a check already answered is ignored'); + } finally { ctx.destroy(); } +}); + +test('the close question on macOS says the app stays in the Dock', async () => { + const ctx = setup(); + ctx.window.api.platform = 'darwin'; + try { + ctx.calls.check(1, 'close'); + await flush(); + assert.equal(ctx.window.document.querySelector('#choice-dialog-title').textContent, 'Close the window?'); + assert.match(ctx.window.document.querySelector('.choice-dialog').textContent, /stays in the Dock/); + } finally { ctx.destroy(); } +}); + +test('a check delivered twice, before and after the page said it is ready, is answered once', async () => { + const ctx = setup(); + try { + ctx.calls.check(9, 'quit'); + ctx.calls.check(9, 'quit'); + await flush(); + assert.deepEqual(ctx.calls.acks.map((a) => a.id), [9]); + assert.deepEqual(ctx.calls.answers, [{ id: 9, proceed: true }]); + } finally { ctx.destroy(); } +}); diff --git a/test/unsaved-guard.test.js b/test/unsaved-guard.test.js index b26822cb1..f4f72ea42 100644 --- a/test/unsaved-guard.test.js +++ b/test/unsaved-guard.test.js @@ -5,11 +5,14 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); const { EventEmitter } = require('node:events'); const { createUnsavedGuard, LOGOFF_CANCEL_MS } = require('../unsaved-guard'); -function setup({ timeoutMs = 1000, quit, closeFinishes = false } = {}) { +function setup({ timeoutMs = 1000, probeMs = 1500, dialogCeilingMs, quit, closeFinishes = false } = {}) { const ipcMain = new EventEmitter(); const timers = []; const setTimeoutFn = (fn, ms) => { const t = { fn, ms, cleared: false }; timers.push(t); return t; }; @@ -33,14 +36,22 @@ function setup({ timeoutMs = 1000, quit, closeFinishes = false } = {}) { destroys: 0, closed: false, }); - const guard = createUnsavedGuard({ ipcMain, timeoutMs, setTimeoutFn, clearTimeoutFn, quit }); + const guard = createUnsavedGuard({ ipcMain, timeoutMs, probeMs, dialogCeilingMs, setTimeoutFn, clearTimeoutFn, quit: quit || (() => win.close()) }); guard.attach(win); const closeEvent = () => ({ prevented: false, preventDefault() { this.prevented = true; } }); const answer = (id, proceed) => ipcMain.emit('unsaved-check-result', {}, id, proceed); const ack = (id) => ipcMain.emit('unsaved-check-ack', {}, id); - const reasonDone = (id) => ipcMain.emit('unsaved-check-reason-done', {}, id); - return { win, wc, sent, timers, closeEvent, answer, ack, reasonDone, ipcMain, guard }; + const reasonDone = (id) => { + for (const m of sent) if (m.channel === 'unsaved-check-reason' && m.args[0] === id) ipcMain.emit('unsaved-check-reason-done', {}, id, m.args[2]); + }; + const pong = (token) => ipcMain.emit('unsaved-pong', { sender: wc }, token); + const pings = () => sent.filter((m) => m.channel === 'unsaved-ping'); + const checks = () => sent.filter((m) => m.channel === 'unsaved-check'); + const reasons = () => sent.filter((m) => m.channel === 'unsaved-check-reason'); + const nativeDialog = (open) => ipcMain.emit('unsaved-dialog', { sender: wc }, open); + const armed = (ms) => timers.filter((x) => x.ms === ms && !x.cleared); + return { win, wc, sent, timers, closeEvent, answer, ack, reasonDone, pong, pings, checks, reasons, nativeDialog, armed, ipcMain, guard }; } const tick = () => new Promise((r) => setImmediate(r)); @@ -52,7 +63,7 @@ test('a close is held while the renderer is asked, and goes through on yes', asy assert.equal(e.prevented, true); assert.equal(t.sent.length, 1); assert.equal(t.sent[0].channel, 'unsaved-check'); - assert.equal(t.sent[0].args[1], 'quit'); + assert.equal(t.sent[0].args[1], 'close', 'a window close is told apart from a quit'); assert.equal(t.win.closes, 0); t.answer(t.sent[0].args[0], true); @@ -83,29 +94,45 @@ test('a second close while the question is open asks nothing more', () => { const e = t.closeEvent(); t.win.emit('close', e); assert.equal(e.prevented, true); - assert.equal(t.sent.length, 1); + assert.equal(t.checks().length, 1); }); -test('a renderer that never answers is given up on after the bound', async () => { - const t = setup({ timeoutMs: 2500 }); - t.win.emit('close', t.closeEvent()); +test('a quit the renderer never acknowledges is given up on after the bound', async () => { + let quits = 0; + const t = setup({ timeoutMs: 2500, quit: () => { quits += 1; } }); + t.guard.beforeQuit(t.closeEvent(), t.win); assert.equal(t.timers.length, 1); assert.equal(t.timers[0].ms, 2500); t.timers[0].fn(); await tick(); + assert.equal(quits, 1); +}); + +test('a window close the renderer has not acknowledged waits: a busy page is asked once it is free', async () => { + const t = setup({ timeoutMs: 2500 }); + t.win.emit('close', t.closeEvent()); + const [mark] = t.armed(2500); + mark.fn(); + await tick(); + assert.equal(t.win.closes + t.win.destroys, 0, 'no bound closes the window without the question'); + t.ack(t.sent[0].args[0]); + t.answer(t.sent[0].args[0], true); + await tick(); assert.equal(t.win.closes, 1); + assert.equal(t.win.destroys, 0); }); test('an answer cancels its timer and a late answer is ignored', async () => { - const t = setup(); - t.win.emit('close', t.closeEvent()); + let quits = 0; + const t = setup({ quit: () => { quits += 1; } }); + t.guard.beforeQuit(t.closeEvent(), t.win); const id = t.sent[0].args[0]; t.answer(id, false); assert.equal(t.timers[0].cleared, true); await tick(); t.answer(id, true); await tick(); - assert.equal(t.win.closes, 0); + assert.equal(quits, 0); }); test('a crashed renderer is not asked', async () => { @@ -146,16 +173,17 @@ test('a blocked unload during an approved close is let through', () => { }); test('an acknowledged check waits for a slow human, past the bound', async () => { - const t = setup({ timeoutMs: 2500 }); - t.win.emit('close', t.closeEvent()); + let quits = 0; + const t = setup({ timeoutMs: 2500, quit: () => { quits += 1; } }); + t.guard.beforeQuit(t.closeEvent(), t.win); const id = t.sent[0].args[0]; t.ack(id); assert.equal(t.timers[0].cleared, true, 'the ack ends the bound'); await tick(); - assert.equal(t.win.closes, 0, 'still waiting for the user'); + assert.equal(quits, 0, 'still waiting for the user'); t.answer(id, true); await tick(); - assert.equal(t.win.closes, 1); + assert.equal(quits, 1); }); test('an acknowledged check gives up when the renderer process goes away', async () => { @@ -257,11 +285,12 @@ test('a Windows session end approves the quit so logoff never waits on the dialo }); test('a window close and a quit share one question, and one answer settles both', async () => { - const t = setup(); + const t = setup({ quit: () => {} }); t.win.emit('close', t.closeEvent()); const e = t.closeEvent(); assert.equal(t.guard.beforeQuit(e, t.win), true); - assert.equal(t.sent.length, 1, 'one dialog, not two'); + assert.equal(t.checks().length, 1, 'one dialog, not two'); + t.reasonDone(t.sent[0].args[0]); t.answer(t.sent[0].args[0], true); await tick(); assert.equal(t.win.closes, 1); @@ -278,7 +307,7 @@ test('a quit that joins an unanswered reload check tells the renderer it is now assert.equal(t.guard.beforeQuit(t.closeEvent(), t.win), true); t.win.emit('close', t.closeEvent()); assert.deepEqual(t.sent.map((m) => m.channel), ['unsaved-check', 'unsaved-check-reason'], 'one upgrade, no second question'); - assert.deepEqual(t.sent[1].args, [id, 'quit']); + assert.deepEqual(t.sent[1].args.slice(0, 2), [id, 'quit']); t.answer(id, true); t.reasonDone(id); await tick(); @@ -357,6 +386,66 @@ test('a page that reports the upgrade done before it answers lets the joined qui assert.equal(t.timers.filter((x) => !x.cleared && x.ms === 1000).length, 0, 'no bounded wait left running'); }); +test('each upgrade of a check is waited for by its own report, not by the first one to arrive', async () => { + const quits = []; + const t = setup({ quit: () => quits.push(1) }); + t.wc.emit('will-prevent-unload', t.closeEvent()); + const id = t.sent[0].args[0]; + t.win.emit('close', t.closeEvent()); + t.guard.beforeQuit(t.closeEvent(), t.win); + const [toClose, toQuit] = t.reasons().map((m) => m.args[2]); + assert.deepEqual(t.reasons().map((m) => m.args.slice(0, 2)), [[id, 'close'], [id, 'quit']]); + t.answer(id, true); + t.ipcMain.emit('unsaved-check-reason-done', {}, id, toQuit); + await tick(); + assert.equal(quits.length, 0, 'the save the close upgrade asked for has not reported yet'); + t.ipcMain.emit('unsaved-check-reason-done', {}, id, toClose); + await tick(); + assert.equal(quits.length, 1); +}); + +test('a check joined by two upgrades stays in flight until the last upgrade reports', async () => { + const quits = []; + const t = setup({ quit: () => quits.push(1) }); + t.wc.emit('will-prevent-unload', t.closeEvent()); + const id = t.sent[0].args[0]; + t.win.emit('close', t.closeEvent()); + t.guard.beforeQuit(t.closeEvent(), t.win); + const [toClose, toQuit] = t.reasons().map((m) => m.args[2]); + t.answer(id, true); + t.ipcMain.emit('unsaved-check-reason-done', {}, id, toClose); + await tick(); + const install = t.guard.confirmQuit(t.win); + await tick(); + assert.equal(t.checks().length, 1, 'an install arriving before the quit upgrade reports joins the question'); + assert.equal(quits.length, 0); + t.ipcMain.emit('unsaved-check-reason-done', {}, id, toQuit); + await tick(); + assert.equal(quits.length, 1); + assert.equal(await install, true); + assert.equal(t.checks().length, 1, 'still one question'); +}); + +test('the preload passes an upgrade token through, and back with the report', () => { + const listeners = new Map(); + const sends = []; + let api; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8'), { + process: { platform: 'fixture', argv: [] }, + require: () => ({ + contextBridge: { exposeInMainWorld(_name, value) { api = value; } }, + ipcRenderer: { on(channel, cb) { listeners.set(channel, cb); }, send(channel, ...args) { sends.push({ channel, args }); } }, + webUtils: {}, + }), + }); + const changed = []; + api.onUnsavedCheckReason((...args) => changed.push(args)); + listeners.get('unsaved-check-reason')({}, 3, 'quit', 9); + assert.deepEqual(changed, [[3, 'quit', 9]]); + api.unsavedCheckReasonDone(3, 9); + assert.deepEqual(sends.at(-1), { channel: 'unsaved-check-reason-done', args: [3, 9] }); +}); + test('a close or quit arriving while a joined quit waits for the late save joins it instead of asking again', async () => { const quits = []; const t = setup({ quit: () => quits.push(1) }); @@ -436,9 +525,10 @@ test('a logoff that never ends the session stops approving quits after a while; }); async function closeUnanswered(t) { - t.win.emit('close', t.closeEvent()); + t.guard.beforeQuit(t.closeEvent(), t.win); t.timers.filter((x) => x.ms === 2500 && !x.cleared).pop().fn(); await tick(); + t.win.emit('close', t.closeEvent()); } test('a page that never acknowledges is closed the usual way, and destroyed once reported unresponsive', async () => { @@ -476,9 +566,7 @@ test('a page already reported unresponsive is destroyed when its unanswered clos test('closing again a window whose unanswered close did not finish destroys it', async () => { const t = setup({ timeoutMs: 2500 }); await closeUnanswered(t); - const ours = t.closeEvent(); - t.win.emit('close', ours); - assert.equal(t.win.destroys, 0, 'the close the guard itself started is not a second one'); + assert.equal(t.win.destroys, 0, 'the close the quit itself started is not a second one'); const again = t.closeEvent(); t.win.emit('close', again); assert.equal(t.win.destroys, 1); @@ -537,6 +625,274 @@ test('an updater install the page never acknowledges is approved, and the window assert.equal(t.guard.beforeQuit(t.closeEvent(), t.win), false, 'the quit that follows is approved'); }); +// see .ai/contexts/window-frame.md ("A window that stops answering") +test('a window close the page never acknowledges ends once Electron reports the page unresponsive', async () => { + const t = setup(); + t.win.emit('close', t.closeEvent()); + t.win.emit('unresponsive'); + await tick(); + assert.equal(t.win.destroys, 1, 'a close() would wait for the hung page\'s beforeunload'); + assert.equal(t.win.closes, 0); +}); + +test('closing again a page that left the check unacknowledged for the bound ends it; a double click does not', async () => { + const t = setup({ timeoutMs: 2500 }); + t.win.emit('close', t.closeEvent()); + t.win.emit('close', t.closeEvent()); + await tick(); + assert.equal(t.win.destroys, 0, 'a double click on the close button is not taken for a hang'); + assert.equal(t.pings().length, 0, 'a page that has not taken the check cannot take a ping either'); + t.armed(2500)[0].fn(); + await tick(); + assert.equal(t.win.destroys, 0, 'the bound alone destroys nothing: a busy page is not hung'); + t.win.emit('close', t.closeEvent()); + await tick(); + assert.equal(t.win.destroys, 1); + assert.equal(t.win.closes, 0); +}); + +test('a second close of a page that has acknowledged pings it, and one that answers keeps waiting for the question', async () => { + const t = setup(); + t.win.emit('close', t.closeEvent()); + t.ack(t.sent[0].args[0]); + t.win.emit('close', t.closeEvent()); + assert.equal(t.pings().length, 1); + t.pong(t.pings()[0].args[0]); + t.win.emit('close', t.closeEvent()); + assert.equal(t.pings().length, 2, 'a later close checks again'); + await tick(); + assert.equal(t.win.destroys, 0); + assert.equal(t.win.closes, 0, 'the user still decides'); +}); + +test('closing again a page that has left a ping unanswered for the probe time ends it', async () => { + const t = setup(); + t.win.emit('close', t.closeEvent()); + t.ack(t.sent[0].args[0]); + t.win.emit('close', t.closeEvent()); + const [overdue] = t.armed(1500); + t.win.emit('close', t.closeEvent()); + await tick(); + assert.equal(t.win.destroys, 0, 'a quick repeat is not enough'); + overdue.fn(); + await tick(); + assert.equal(t.win.destroys, 0, 'the probe time alone destroys nothing: a busy page is not hung'); + t.win.emit('close', t.closeEvent()); + await tick(); + assert.equal(t.win.destroys, 1); +}); + +test('an acknowledged check is ended by unresponsive only while a ping waits for an answer', async () => { + const t = setup(); + t.win.emit('close', t.closeEvent()); + t.ack(t.sent[0].args[0]); + t.win.emit('unresponsive'); + await tick(); + assert.equal(t.win.destroys, 0, 'the user may take their time over the question'); + t.win.emit('close', t.closeEvent()); + t.win.emit('unresponsive'); + await tick(); + assert.equal(t.win.destroys, 1, 'a page that took the check, then hung'); +}); + +test('a quit or an updater install never counts as a second close', async () => { + const t = setup({ quit: () => {} }); + t.win.emit('close', t.closeEvent()); + t.ack(t.sent[0].args[0]); + t.guard.beforeQuit(t.closeEvent(), t.win); + t.guard.confirmQuit(t.win); + assert.equal(t.pings().length, 0); + assert.deepEqual(t.armed(1500), []); +}); + +test('a quit that joins a close question tells the page, and a page that has not acknowledged gets the quit bound', async () => { + let quits = 0; + const t = setup({ timeoutMs: 2500, quit: () => { quits += 1; } }); + t.win.emit('close', t.closeEvent()); + const [mark] = t.armed(2500); + t.guard.beforeQuit(t.closeEvent(), t.win); + assert.equal(mark.cleared, true); + assert.deepEqual(t.reasons().map((m) => m.args.slice(0, 2)), [[t.sent[0].args[0], 'quit']]); + const [bound] = t.armed(2500); + assert.ok(bound, 'SIGTERM or a logout cannot wait for ever on a busy page'); + bound.fn(); + await tick(); + assert.equal(quits, 1); + assert.equal(t.win.closes, 1); +}); + +test('a reload question joined by a close becomes a close, and a quit is never turned back into a close', () => { + const t = setup(); + t.wc.emit('will-prevent-unload', t.closeEvent()); + t.win.emit('close', t.closeEvent()); + assert.deepEqual(t.reasons().map((m) => m.args[1]), ['close']); + + const u = setup({ quit: () => {} }); + u.guard.beforeQuit(u.closeEvent(), u.win); + u.win.emit('close', u.closeEvent()); + assert.deepEqual(u.reasons(), []); + assert.equal(u.armed(1000).length, 1, 'the quit keeps its bound'); +}); + +test('a quit the page never acknowledges goes ahead at once when Electron reports the page unresponsive, and ends the window it closes', async () => { + let quits = 0; + const t = setup({ quit: () => { quits += 1; } }); + t.guard.beforeQuit(t.closeEvent(), t.win); + t.win.emit('unresponsive'); + await tick(); + assert.equal(quits, 1); + assert.equal(t.win.destroys, 0, 'the quit may still be held by another before-quit handler'); + t.win.emit('close', t.closeEvent()); + assert.equal(t.win.destroys, 1); +}); + +test('the shipped preload answers a ping once the check handler is registered', () => { + const listeners = new Map(); + const sends = []; + let api; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8'), { + process: { platform: 'fixture', argv: [] }, + require: () => ({ + contextBridge: { exposeInMainWorld(_name, value) { api = value; } }, + ipcRenderer: { on(channel, cb) { listeners.set(channel, cb); }, send(channel, ...args) { sends.push({ channel, args }); } }, + webUtils: {}, + }), + }); + assert.equal(listeners.has('unsaved-ping'), false); + api.onUnsavedCheck(() => {}); + assert.deepEqual(sends, [{ channel: 'unsaved-ready', args: [] }], 'the page says it can answer a check'); + listeners.get('unsaved-ping')({}, 7); + assert.deepEqual(sends[1], { channel: 'unsaved-pong', args: [7] }); + const changed = []; + api.onUnsavedCheckReason((id, reason) => changed.push([id, reason])); + listeners.get('unsaved-check-reason')({}, 3, 'quit'); + assert.deepEqual(changed, [[3, 'quit']]); +}); + +test('a native dialog open in the page holds the probe: no ping, no force', async () => { + const t = setup({ timeoutMs: 2500 }); + t.win.emit('close', t.closeEvent()); + t.armed(2500)[0].fn(); + t.nativeDialog(true); + assert.deepEqual(t.armed(2500), [], 'the bound restarts once the dialog is answered'); + t.win.emit('close', t.closeEvent()); + t.win.emit('close', t.closeEvent()); + t.win.emit('unresponsive'); + await tick(); + assert.equal(t.win.destroys, 0, 'the user can still answer the dialog'); + t.nativeDialog(false); + t.win.emit('close', t.closeEvent()); + await tick(); + assert.equal(t.win.destroys, 0, 'the bound starts from zero'); + t.armed(2500)[0].fn(); + t.win.emit('close', t.closeEvent()); + await tick(); + assert.equal(t.win.destroys, 1); + + const u = setup(); + u.win.emit('close', u.closeEvent()); + u.ack(u.sent[0].args[0]); + u.nativeDialog(true); + u.win.emit('close', u.closeEvent()); + assert.equal(u.pings().length, 0); + u.nativeDialog(false); + u.win.emit('close', u.closeEvent()); + assert.equal(u.pings().length, 1, 'probing resumes once it is answered'); +}); + +test('a check sent while a native dialog is open waits for it before the bound before the ack runs', async () => { + const t = setup({ timeoutMs: 2500 }); + t.nativeDialog(true); + t.guard.beforeQuit(t.closeEvent(), t.win); + assert.deepEqual(t.armed(2500), []); + t.nativeDialog(false); + assert.equal(t.armed(2500).length, 1); + t.ack(t.sent[0].args[0]); + assert.deepEqual(t.armed(2500), []); +}); + +test('a dialog reported by another page, or by a page that crashed since, does not pause the bounds', () => { + const t = setup({ timeoutMs: 2500 }); + t.ipcMain.emit('unsaved-dialog', { sender: {} }, true); + t.guard.beforeQuit(t.closeEvent(), t.win); + assert.equal(t.armed(2500).length, 1); + + const u = setup({ timeoutMs: 2500 }); + u.nativeDialog(true); + u.wc.emit('render-process-gone'); + u.guard.beforeQuit(u.closeEvent(), u.win); + assert.equal(u.armed(2500).length, 1); + + const v = setup({ timeoutMs: 2500 }); + v.nativeDialog(true); + v.wc.emit('did-navigate'); + v.guard.beforeQuit(v.closeEvent(), v.win); + assert.equal(v.armed(2500).length, 1); +}); + +test('the shipped preload reports a native dialog to main', () => { + const sends = []; + let api; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8'), { + process: { platform: 'fixture', argv: [] }, + require: () => ({ + contextBridge: { exposeInMainWorld(_name, value) { api = value; } }, + ipcRenderer: { on() {}, send(channel, ...args) { sends.push({ channel, args }); } }, + webUtils: {}, + }), + }); + api.unsavedDialog(true); + api.unsavedDialog(false); + assert.deepEqual(sends, [{ channel: 'unsaved-dialog', args: [true] }, { channel: 'unsaved-dialog', args: [false] }]); +}); + +test('a ping that cannot be sent ends the check at once, as a hung page would', async () => { + const t = setup(); + t.win.emit('close', t.closeEvent()); + t.ack(t.sent[0].args[0]); + t.wc.send = () => { throw new Error('gone'); }; + t.win.emit('close', t.closeEvent()); + await tick(); + assert.equal(t.win.destroys, 1); +}); + +test('a native dialog pauses the quit bound for at most the ceiling, so a quit (SIGTERM, a logout) cannot wait for ever', async () => { + let quits = 0; + const t = setup({ timeoutMs: 2500, dialogCeilingMs: 300000, quit: () => { quits += 1; } }); + t.nativeDialog(true); + assert.equal(t.guard.beforeQuit(t.closeEvent(), t.win), true); + assert.deepEqual(t.armed(2500), []); + const [ceiling] = t.armed(300000); + assert.ok(ceiling, 'the page cannot acknowledge while the dialog blocks it'); + ceiling.fn(); + await tick(); + assert.equal(quits, 1); + t.win.emit('close', t.closeEvent()); + t.win.emit('unresponsive'); + assert.equal(t.win.destroys, 1, 'the blocked page never runs its beforeunload either'); +}); + +test('a pong from another page does not answer the probe', async () => { + const t = setup(); + t.win.emit('close', t.closeEvent()); + t.ack(t.sent[0].args[0]); + t.win.emit('close', t.closeEvent()); + const token = t.pings()[0].args[0]; + t.ipcMain.emit('unsaved-pong', { sender: {} }, token); + t.win.emit('unresponsive'); + await tick(); + assert.equal(t.win.destroys, 1, 'still waiting for this page'); + + const u = setup(); + u.win.emit('close', u.closeEvent()); + u.ack(u.sent[0].args[0]); + u.win.emit('close', u.closeEvent()); + u.pong(u.pings()[0].args[0]); + u.win.emit('unresponsive'); + await tick(); + assert.equal(u.win.destroys, 0); +}); + test('a quit held by another before-quit handler does not destroy the window before it closes it', async () => { let quits = 0; const t = setup({ timeoutMs: 2500, quit: () => { quits += 1; } }); @@ -564,3 +920,26 @@ test('a quit held by another before-quit handler does not destroy the window bef recovered.win.emit('unresponsive'); assert.equal(recovered.win.destroys, 1); }); + +test('a check sent before the page listened is sent again once the page is ready, and only to that page', () => { + const t = setup(); + t.win.emit('close', t.closeEvent()); + const [id, reason] = t.checks()[0].args; + t.ipcMain.emit('unsaved-ready', { sender: {} }); + assert.equal(t.checks().length, 1, 'another page being ready changes nothing'); + t.ipcMain.emit('unsaved-ready', { sender: t.wc }); + assert.deepEqual(t.checks().map((m) => m.args), [[id, reason], [id, reason]]); + t.ack(id); + t.ipcMain.emit('unsaved-ready', { sender: t.wc }); + assert.equal(t.checks().length, 2, 'an acknowledged check is not sent again'); + + const upgraded = setup(); + upgraded.win.emit('close', upgraded.closeEvent()); + upgraded.guard.beforeQuit(upgraded.closeEvent(), upgraded.win); + upgraded.ipcMain.emit('unsaved-ready', { sender: upgraded.wc }); + assert.equal(upgraded.checks()[1].args[1], 'quit', 'sent again with the reason it holds now'); + + const idle = setup(); + idle.ipcMain.emit('unsaved-ready', { sender: idle.wc }); + assert.equal(idle.sent.length, 0); +}); diff --git a/unsaved-guard.js b/unsaved-guard.js index 55c4d62ad..938ff6787 100644 --- a/unsaved-guard.js +++ b/unsaved-guard.js @@ -3,26 +3,51 @@ // see .ai/contexts/viewer-panel.md ("Unsaved edits on quit, reload and close") const DEFAULT_TIMEOUT_MS = 2500; +const DEFAULT_PROBE_MS = 1500; +const DEFAULT_DIALOG_CEILING_MS = 5 * 60 * 1000; +const REASON_RANK = { reload: 0, close: 1, quit: 2 }; const LOGOFF_CANCEL_MS = 60000; -function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCancelMs = LOGOFF_CANCEL_MS, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, quit = () => {} }) { +function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, probeMs = DEFAULT_PROBE_MS, dialogCeilingMs = DEFAULT_DIALOG_CEILING_MS, logoffCancelMs = LOGOFF_CANCEL_MS, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, quit = () => {} }) { const pending = new Map(); + const probes = new Map(); + const dialogs = new WeakSet(); + const windows = new WeakMap(); let nextId = 1; let quitApproved = false; let logoffApproved = false; let logoffTimer = null; let quitAsking = false; let inflight = null; + let current = null; const approvedToQuit = () => quitApproved || logoffApproved; - const windows = new WeakMap(); + + ipcMain.on('unsaved-pong', (event, token) => { + const probed = probes.get(token); + if (probed && event && event.sender === probed.wc) probed.answered(); + }); + + // see .ai/contexts/window-frame.md ("A native dialog pauses the bounds") + ipcMain.on('unsaved-dialog', (event, open) => { + const wc = event && event.sender; + if (!wc) return; + if (open === true) dialogs.add(wc); + else dialogs.delete(wc); + if (current && current.wc === wc) current.arm(); + }); + + ipcMain.on('unsaved-ready', (event) => { + const entry = current; + if (!entry || entry.acked || !event || event.sender !== entry.wc) return; + try { entry.wc.send('unsaved-check', entry.id, entry.reason); } catch {} + }); ipcMain.on('unsaved-check-ack', (_event, id) => { const entry = pending.get(id); if (!entry || entry.acked) return; entry.acked = true; - clearTimeoutFn(entry.timer); - const state = windows.get(entry.win); - if (state) state.unresponsive = false; + responsive(entry.win); + entry.arm(); }); ipcMain.on('unsaved-check-result', (_event, id, proceed) => { @@ -32,71 +57,103 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan }); const reasonWaits = new Map(); - ipcMain.on('unsaved-check-reason-done', (_event, id) => { - const done = reasonWaits.get(id); - if (done) done(); + ipcMain.on('unsaved-check-reason-done', (_event, _id, token) => { + const release = reasonWaits.get(token); + if (release) release(); }); - function waitReasonDone(id, wc) { + function waitReasonDone(token, wc) { let release = null; const done = new Promise((resolve) => { const timer = setTimeoutFn(() => release(), timeoutMs); const onGone = () => release(); release = () => { - reasonWaits.delete(id); + reasonWaits.delete(token); clearTimeoutFn(timer); wc.removeListener('render-process-gone', onGone); wc.removeListener('destroyed', onGone); resolve(); }; - reasonWaits.set(id, release); + reasonWaits.set(token, release); wc.on('render-process-gone', onGone); wc.on('destroyed', onGone); }); return { done, release }; } + function responsive(win) { + const state = windows.get(win); + if (state) state.unresponsive = false; + } + + // see .ai/contexts/window-frame.md ("A window that stops answering") function askFull(win, reason) { if (inflight) { - if (inflight.reason === 'reload') { + if (REASON_RANK[reason] > REASON_RANK[inflight.entry.reason]) { const joined = inflight; - joined.reason = reason; joined.held = true; - const { id } = joined; - const wait = waitReasonDone(id, win.webContents); - try { win.webContents.send('unsaved-check-reason', id, reason); } catch { wait.release(); } + joined.upgrades = (joined.upgrades || 0) + 1; + const token = nextId++; + const sent = joined.entry.upgrade(reason, token); + const wait = waitReasonDone(token, win.webContents); + if (!sent) wait.release(); joined.asked = joined.asked.then((result) => { - if (!result.proceed || result.unanswered) { + if (!result.proceed || result.ended) { wait.release(); return result; } return wait.done.then(() => result); }).then((result) => { - if (inflight === joined) inflight = null; + joined.upgrades -= 1; + if (joined.upgrades === 0 && inflight === joined) inflight = null; return result; }); } return inflight.asked; } const wc = win.webContents; - if (win.isDestroyed() || !wc || wc.isDestroyed() || wc.isCrashed()) return Promise.resolve({ proceed: true, unanswered: false }); + if (win.isDestroyed() || !wc || wc.isDestroyed() || wc.isCrashed()) return Promise.resolve({ proceed: true, ended: null }); let settled = false; const id = nextId++; + const entry = { id, win, wc, reason, acked: false, overdue: false, probe: null, timer: null, finish: null, arm: null, upgrade: null }; const asked = new Promise((resolve) => { - const entry = { win, acked: false, timer: null, finish: null }; const onGone = () => entry.finish(true); - entry.finish = (proceed, unanswered = false) => { + const stop = () => { + if (entry.timer) clearTimeoutFn(entry.timer); + entry.timer = null; + }; + entry.arm = () => { + stop(); + entry.overdue = false; + if (entry.acked) return; + if (entry.reason !== 'close') entry.timer = setTimeoutFn(() => entry.finish(true, 'unanswered'), dialogs.has(wc) ? dialogCeilingMs : timeoutMs); + else if (!dialogs.has(wc)) entry.timer = setTimeoutFn(() => { entry.overdue = true; }, timeoutMs); + }; + entry.upgrade = (to, token) => { + entry.reason = to; + let sent = true; + try { wc.send('unsaved-check-reason', id, to, token); } catch { sent = false; } + entry.arm(); + return sent; + }; + entry.finish = (proceed, ended = null) => { if (!pending.has(id)) return; pending.delete(id); - clearTimeoutFn(entry.timer); + stop(); + if (entry.probe) { + clearTimeoutFn(entry.probe.timer); + probes.delete(entry.probe.token); + } wc.removeListener('render-process-gone', onGone); wc.removeListener('destroyed', onGone); settled = true; - if (inflight && inflight.id === id && !inflight.held) inflight = null; - resolve({ proceed, unanswered }); + if (inflight && inflight.entry === entry && !inflight.held) inflight = null; + if (current === entry) current = null; + resolve({ proceed, ended }); }; - entry.timer = setTimeoutFn(() => entry.finish(true, true), timeoutMs); pending.set(id, entry); + current = entry; + entry.arm(); wc.on('render-process-gone', onGone); wc.on('destroyed', onGone); try { @@ -105,7 +162,7 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan entry.finish(true); } }); - if (!settled) inflight = { asked, id, reason }; + if (!settled) inflight = { asked, entry }; return asked; } @@ -113,6 +170,42 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan return askFull(win, reason).then(({ proceed }) => proceed); } + function probeOrForce(entry) { + if (dialogs.has(entry.wc)) return; + if (!entry.acked) { + if (entry.overdue) entry.finish(true, 'hung'); + return; + } + if (entry.probe) { + if (entry.probe.overdue) entry.finish(true, 'hung'); + return; + } + const token = nextId++; + const probe = { token, overdue: false, timer: null }; + probe.timer = setTimeoutFn(() => { probe.overdue = true; }, probeMs); + entry.probe = probe; + probes.set(token, { + wc: entry.wc, + answered: () => { + probes.delete(token); + clearTimeoutFn(probe.timer); + if (entry.probe === probe) entry.probe = null; + responsive(entry.win); + }, + }); + try { + entry.wc.send('unsaved-ping', token); + } catch { + entry.finish(true, 'hung'); + } + } + + function unresponsiveDuringCheck(win) { + const entry = current; + if (!entry || entry.win !== win || dialogs.has(entry.wc)) return; + if (!entry.acked || entry.probe) entry.finish(true, 'hung'); + } + function force(win) { if (!win.isDestroyed()) win.destroy(); } @@ -137,11 +230,11 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan event.preventDefault(); if (quitAsking) return true; quitAsking = true; - askFull(win, 'quit').then(({ proceed, unanswered }) => { + askFull(win, 'quit').then(({ proceed, ended }) => { quitAsking = false; if (!proceed) return; quitApproved = true; - if (unanswered) markQuitUnanswered(win); + if (ended) markQuitUnanswered(win); quit(); }); return true; @@ -171,8 +264,11 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan win.on('unresponsive', () => { state.unresponsive = true; if (state.unanswered) force(win); + else unresponsiveDuringCheck(win); }); win.on('responsive', () => { state.unresponsive = false; }); + win.webContents.on('render-process-gone', () => dialogs.delete(win.webContents)); + win.webContents.on('did-navigate', () => dialogs.delete(win.webContents)); win.on('close', (event) => { if (approved || approvedToQuit()) { @@ -191,16 +287,20 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan return; } event.preventDefault(); - if (closing) return; + if (closing) { + if (current && current.win === win) probeOrForce(current); + return; + } closing = true; - askFull(win, 'quit').then(({ proceed, unanswered }) => { + askFull(win, 'close').then(({ proceed, ended }) => { closing = false; if (!proceed) return; approved = true; + if (ended === 'hung') force(win); if (win.isDestroyed()) return; - if (unanswered) markUnanswered(win); + if (ended === 'unanswered') markUnanswered(win); win.close(); - if (unanswered) forceIfUnresponsive(win); + if (ended === 'unanswered') forceIfUnresponsive(win); }); }); @@ -216,7 +316,7 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan const check = inflight; asked.then((proceed) => { reloading = false; - if (check && check.reason !== 'reload') return; + if (check && check.entry.reason !== 'reload') return; if (!proceed || win.isDestroyed() || win.webContents.isDestroyed()) return; allowNextUnload = true; win.webContents.reload(); @@ -225,10 +325,10 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan } function confirmQuit(win) { - return askFull(win, 'quit').then(({ proceed, unanswered }) => { + return askFull(win, 'quit').then(({ proceed, ended }) => { if (!proceed) return false; quitApproved = true; - if (unanswered) markQuitUnanswered(win); + if (ended) markQuitUnanswered(win); return true; }); } @@ -240,4 +340,4 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan return { attach, beforeQuit, confirmQuit, approveQuit }; } -module.exports = { createUnsavedGuard, DEFAULT_TIMEOUT_MS, LOGOFF_CANCEL_MS }; +module.exports = { createUnsavedGuard, DEFAULT_TIMEOUT_MS, DEFAULT_PROBE_MS, DEFAULT_DIALOG_CEILING_MS, LOGOFF_CANCEL_MS };