From 9eea5f617fbac97c0309f2179593251e4f86fc18 Mon Sep 17 00:00:00 2001 From: mukunda katta Date: Sat, 25 Apr 2026 16:37:37 -0700 Subject: [PATCH] feat(quit): warn before quitting when sessions have running processes Adds a `confirmOnQuit` config option that intercepts `before-quit` and shows a native dialog when there are open sessions with a live pty. The default `'on-running-process'` only prompts when needed; users can opt in to `'always'` or out with `'never'`. Closes #67 --- app/config/config-default.json | 1 + app/config/schema.json | 10 +++ app/index.ts | 16 ++++ app/utils/confirm-quit.ts | 71 ++++++++++++++++++ test/unit/confirm-quit.test.ts | 129 +++++++++++++++++++++++++++++++++ typings/config.d.ts | 7 ++ 6 files changed, 234 insertions(+) create mode 100644 app/utils/confirm-quit.ts create mode 100644 test/unit/confirm-quit.test.ts diff --git a/app/config/config-default.json b/app/config/config-default.json index 2a6a66ff618a..4cb7280e083d 100644 --- a/app/config/config-default.json +++ b/app/config/config-default.json @@ -60,6 +60,7 @@ "disableLigatures": true, "disableAutoUpdates": false, "autoUpdatePlugins": true, + "confirmOnQuit": "on-running-process", "preserveCWD": true, "screenReaderMode": false, "imageSupport": true, diff --git a/app/config/schema.json b/app/config/schema.json index 6bcf850036eb..21587ae8f997 100644 --- a/app/config/schema.json +++ b/app/config/schema.json @@ -324,6 +324,15 @@ "boolean" ] }, + "confirmOnQuit": { + "description": "Show a confirmation dialog before quitting Hyper.\n- `'always'`: always confirm before quit\n- `'on-running-process'`: only confirm when at least one session has a running process (default)\n- `'never'`: never confirm", + "enum": [ + "always", + "never", + "on-running-process" + ], + "type": "string" + }, "defaultSSHApp": { "description": "if `true` hyper will be set as the default protocol client for SSH", "type": "boolean" @@ -346,6 +355,7 @@ }, "required": [ "autoUpdatePlugins", + "confirmOnQuit", "defaultSSHApp", "disableAutoUpdates", "updateChannel" diff --git a/app/index.ts b/app/index.ts index 84a804a4da14..d439fcb0466c 100644 --- a/app/index.ts +++ b/app/index.ts @@ -35,6 +35,7 @@ import * as AppMenu from './menus/menu'; import * as plugins from './plugins'; import {newWindow} from './ui/window'; import {installCLI} from './utils/cli-install'; +import {shouldAllowQuit} from './utils/confirm-quit'; import * as windowUtils from './utils/window-utils'; const windowSet = new Set([]); @@ -173,6 +174,21 @@ app.on('ready', () => } }); + // Confirm before quitting if the user still has running processes. + // Tracking guards against re-prompting when the user has already + // confirmed once (e.g. plugins triggering `app.quit()` again). + let quitConfirmed = false; + app.on('before-quit', (event) => { + if (quitConfirmed) return; + const mode = config.getConfig().confirmOnQuit; + const allow = shouldAllowQuit(mode, windowSet); + if (!allow) { + event.preventDefault(); + } else { + quitConfirmed = true; + } + }); + const makeMenu = () => { const menu = plugins.decorateMenu(AppMenu.createMenu(createWindow, plugins.getLoadedPluginVersions)); diff --git a/app/utils/confirm-quit.ts b/app/utils/confirm-quit.ts new file mode 100644 index 000000000000..7ceeab32416f --- /dev/null +++ b/app/utils/confirm-quit.ts @@ -0,0 +1,71 @@ +import {app, dialog} from 'electron'; +import type {BrowserWindow} from 'electron'; + +import type {configOptions} from '../../typings/config'; + +/** + * Returns `true` if at least one session in any open window has a live `pty` + * that has not been marked as ended. We treat that as "a running process". + * + * We intentionally avoid inspecting child process state via `ps`/native APIs + * here because that would require a deeper shell integration. The presence + * of a non-ended pty is a reasonable proxy: a fresh shell with nothing + * running still counts (matches what the user generally expects: closing + * Hyper means losing whatever they were doing). + */ +export function hasRunningProcess(windows: Iterable): boolean { + for (const win of windows) { + const sessions = win?.sessions; + if (!sessions || typeof sessions.values !== 'function') continue; + for (const session of sessions.values()) { + // `pty` is set on init and `ended` is flipped to true when the session exits. + if (session && session.pty && !session.ended) { + return true; + } + } + } + return false; +} + +/** + * Shows the native confirm dialog. Returns `true` if the user chose to quit. + * Extracted so tests can stub the dialog easily. + */ +export function showQuitConfirmDialog(parent?: BrowserWindow): boolean { + const result = dialog.showMessageBoxSync(parent ?? (undefined as unknown as BrowserWindow), { + type: 'question', + buttons: ['Cancel', 'Quit'], + defaultId: 0, + cancelId: 0, + title: 'Quit Hyper?', + message: 'Are you sure you want to quit Hyper?', + detail: 'There is at least one session with a running process. Quitting will terminate it.' + }); + return result === 1; +} + +export type ConfirmOnQuitMode = configOptions['confirmOnQuit']; + +/** + * Decide whether to allow the quit based on the current `confirmOnQuit` mode + * and the state of open sessions. Returns `true` if quit should proceed. + * + * The dialog is only shown when needed; an unknown mode falls back to + * `'on-running-process'` to preserve the documented default. + */ +export function shouldAllowQuit( + mode: ConfirmOnQuitMode | undefined, + windows: Iterable, + showDialog: (parent?: BrowserWindow) => boolean = showQuitConfirmDialog +): boolean { + const effective: ConfirmOnQuitMode = + mode === 'always' || mode === 'never' || mode === 'on-running-process' ? mode : 'on-running-process'; + + if (effective === 'never') return true; + if (effective === 'on-running-process' && !hasRunningProcess(windows)) return true; + + // `BrowserWindow.getFocusedWindow()` is read lazily so this module can be + // imported in environments where electron isn't fully initialized (tests). + const focused = app?.getLastFocusedWindow ? app.getLastFocusedWindow() ?? undefined : undefined; + return showDialog(focused); +} diff --git a/test/unit/confirm-quit.test.ts b/test/unit/confirm-quit.test.ts new file mode 100644 index 000000000000..62e0ff7b33be --- /dev/null +++ b/test/unit/confirm-quit.test.ts @@ -0,0 +1,129 @@ +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import test from 'ava'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const proxyquire = require('proxyquire').noCallThru(); + +const loadModule = (dialogStub?: {showMessageBoxSync: (...args: any[]) => number}) => + proxyquire('../../app/utils/confirm-quit', { + electron: { + app: { + getLastFocusedWindow: () => null + }, + dialog: dialogStub ?? { + showMessageBoxSync: () => 0 + } + } + }); + +const makeSession = (overrides: {pty?: any; ended?: boolean} = {}) => ({ + pty: 'pty' in overrides ? overrides.pty : {pid: 123}, + ended: overrides.ended ?? false +}); + +const makeWindow = (sessions: Array>) => { + const map = new Map>(); + sessions.forEach((s, i) => map.set(`uid-${i}`, s)); + return {sessions: map} as any; +}; + +test('hasRunningProcess returns true when any session has a live pty', (t) => { + const {hasRunningProcess} = loadModule(); + const win = makeWindow([makeSession()]); + t.true(hasRunningProcess([win])); +}); + +test('hasRunningProcess returns false when sessions are ended', (t) => { + const {hasRunningProcess} = loadModule(); + const win = makeWindow([makeSession({ended: true})]); + t.false(hasRunningProcess([win])); +}); + +test('hasRunningProcess returns false when no windows', (t) => { + const {hasRunningProcess} = loadModule(); + t.false(hasRunningProcess([])); +}); + +test('hasRunningProcess returns false when sessions have no pty', (t) => { + const {hasRunningProcess} = loadModule(); + const win = makeWindow([makeSession({pty: null})]); + t.false(hasRunningProcess([win])); +}); + +test("shouldAllowQuit allows quit when mode is 'never'", (t) => { + const {shouldAllowQuit} = loadModule(); + let dialogCalls = 0; + const allow = shouldAllowQuit('never', [makeWindow([makeSession()])], () => { + dialogCalls += 1; + return false; + }); + t.true(allow); + t.is(dialogCalls, 0); +}); + +test("shouldAllowQuit prompts when mode is 'always'", (t) => { + const {shouldAllowQuit} = loadModule(); + let dialogCalls = 0; + // No running sessions, but mode is 'always' so the dialog should still fire. + const allow = shouldAllowQuit('always', [makeWindow([makeSession({ended: true})])], () => { + dialogCalls += 1; + return true; + }); + t.true(allow); + t.is(dialogCalls, 1); +}); + +test("shouldAllowQuit returns false when user cancels in 'always' mode", (t) => { + const {shouldAllowQuit} = loadModule(); + const allow = shouldAllowQuit('always', [makeWindow([makeSession()])], () => false); + t.false(allow); +}); + +test("shouldAllowQuit skips dialog in 'on-running-process' when nothing runs", (t) => { + const {shouldAllowQuit} = loadModule(); + let dialogCalls = 0; + const allow = shouldAllowQuit('on-running-process', [makeWindow([makeSession({ended: true})])], () => { + dialogCalls += 1; + return true; + }); + t.true(allow); + t.is(dialogCalls, 0); +}); + +test("shouldAllowQuit prompts in 'on-running-process' when something runs", (t) => { + const {shouldAllowQuit} = loadModule(); + let dialogCalls = 0; + const allow = shouldAllowQuit('on-running-process', [makeWindow([makeSession()])], () => { + dialogCalls += 1; + return true; + }); + t.true(allow); + t.is(dialogCalls, 1); +}); + +test('shouldAllowQuit falls back to default when mode is unknown', (t) => { + const {shouldAllowQuit} = loadModule(); + let dialogCalls = 0; + // Unknown mode + running process -> behaves like 'on-running-process'. + const allow = shouldAllowQuit('bogus' as any, [makeWindow([makeSession()])], () => { + dialogCalls += 1; + return false; + }); + t.false(allow); + t.is(dialogCalls, 1); +}); + +test('showQuitConfirmDialog returns true when user picks Quit', (t) => { + const {showQuitConfirmDialog} = loadModule({ + showMessageBoxSync: () => 1 + }); + t.true(showQuitConfirmDialog()); +}); + +test('showQuitConfirmDialog returns false when user picks Cancel', (t) => { + const {showQuitConfirmDialog} = loadModule({ + showMessageBoxSync: () => 0 + }); + t.false(showQuitConfirmDialog()); +}); diff --git a/typings/config.d.ts b/typings/config.d.ts index 7a6053e93fe3..011f40459b2d 100644 --- a/typings/config.d.ts +++ b/typings/config.d.ts @@ -25,6 +25,13 @@ type rootConfigOptions = { * you can also set it to a custom time e.g. `1d` or `2h` */ autoUpdatePlugins: boolean | string; + /** + * Show a confirmation dialog before quitting Hyper. + * - `'always'`: always confirm before quit + * - `'on-running-process'`: only confirm when at least one session has a running process (default) + * - `'never'`: never confirm + */ + confirmOnQuit: 'always' | 'on-running-process' | 'never'; /** if `true` hyper will be set as the default protocol client for SSH */ defaultSSHApp: boolean; /** if `true` hyper will not check for updates */