Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/config/config-default.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"disableLigatures": true,
"disableAutoUpdates": false,
"autoUpdatePlugins": true,
"confirmOnQuit": "on-running-process",
"preserveCWD": true,
"screenReaderMode": false,
"imageSupport": true,
Expand Down
10 changes: 10 additions & 0 deletions app/config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -346,6 +355,7 @@
},
"required": [
"autoUpdatePlugins",
"confirmOnQuit",
"defaultSSHApp",
"disableAutoUpdates",
"updateChannel"
Expand Down
16 changes: 16 additions & 0 deletions app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BrowserWindow>([]);
Expand Down Expand Up @@ -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));

Expand Down
71 changes: 71 additions & 0 deletions app/utils/confirm-quit.ts
Original file line number Diff line number Diff line change
@@ -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<BrowserWindow>): 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<BrowserWindow>,
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);
}
129 changes: 129 additions & 0 deletions test/unit/confirm-quit.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof makeSession>>) => {
const map = new Map<string, ReturnType<typeof makeSession>>();
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());
});
7 changes: 7 additions & 0 deletions typings/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down