Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/main/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,8 @@ export class App extends EventEmitter {
* Begin shutting the app down. Emits the cancelable `before-quit` then
* `will-quit` events (a listener may call `preventDefault()` on the passed
* event to abort the quit); if neither vetoes, emits `quit` with the exit code
* and exits the process. The native bootstrap listens for `will-quit` to stop
* the run loop before the process exits.
* and exits the process. The native bootstrap listens for `quit` to stop the
* run loop before the process exits.
*/
quit(exitCode = 0): void {
if (this.#quitting) {
Expand Down
5 changes: 4 additions & 1 deletion src/main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,7 @@ export const resetBootstrapForTesting = (): void => {
};

app.setStartHook(ensureNativeStarted);
app.on('will-quit', () => nativeApp().quit());
// `quit` fires only after `before-quit` and `will-quit` had their chance to veto,
// so a vetoed quit leaves the run loop pumping (stopping it on `will-quit` killed
// every later native callback in an app that cancelled its own quit).
app.on('quit', () => nativeApp().quit());
13 changes: 13 additions & 0 deletions tests/helpers/safe-app-exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,16 @@ export const installSafeAppExit = (): void => {
}),
);
};

/**
* Behave like a real macOS app: a `window-all-closed` listener suppresses the
* default quit, so closing the last window does not stop the native run loop.
* Returns the disposer; call it in `afterAll`.
*/
export const keepAppAlive = (): (() => void) => {
const listener = (): void => undefined;
app.on('window-all-closed', listener);
return () => {
app.removeListener('window-all-closed', listener);
};
};
10 changes: 7 additions & 3 deletions tests/integration/macos/browser-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,26 @@ import { currentPlatform } from '../../../src/common/platform';
import { BrowserWindow } from '../../../src/main/api/browser-window';
import { resetBootstrapForTesting } from '../../../src/main/bootstrap';
import { nativeApp, setNativeAppForTesting } from '../../../src/main/native-app';
import { installSafeAppExit } from '../../helpers/safe-app-exit';
import { installSafeAppExit, keepAppAlive } from '../../helpers/safe-app-exit';

const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));

let stopKeepAlive = (): void => undefined;

if (currentPlatform() === 'macos') {
describe('BrowserWindow on the real macOS backend', () => {
beforeAll(() => {
// Ensure the real native backend is used (other suites may have injected a fake).
setNativeAppForTesting(undefined);
resetBootstrapForTesting();
// Closing the last window triggers the window-all-closed default quit;
// keep it from terminating the shared test process.
// A real app keeps running after its last window closes; without the
// listener the default quit would stop the run loop under later tests.
installSafeAppExit();
stopKeepAlive = keepAppAlive();
});

afterAll(() => {
stopKeepAlive();
nativeApp().quit();
});

Expand Down
10 changes: 7 additions & 3 deletions tests/integration/macos/window-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { currentPlatform } from '../../../src/common/platform';
import { BrowserWindow } from '../../../src/main/api/browser-window';
import { resetBootstrapForTesting } from '../../../src/main/bootstrap';
import { nativeApp, setNativeAppForTesting } from '../../../src/main/native-app';
import { installSafeAppExit } from '../../helpers/safe-app-exit';
import { installSafeAppExit, keepAppAlive } from '../../helpers/safe-app-exit';

/**
* BrowserWindow lifecycle events + the close-path teardown on a REAL NSWindow.
Expand All @@ -21,17 +21,21 @@ import { installSafeAppExit } from '../../helpers/safe-app-exit';

const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));

let stopKeepAlive = (): void => undefined;

if (currentPlatform() === 'macos') {
describe('BrowserWindow lifecycle events on the real macOS backend', () => {
beforeAll(() => {
setNativeAppForTesting(undefined);
resetBootstrapForTesting();
// Closing the last window triggers the window-all-closed default quit;
// keep it from terminating the shared test process.
// A real app keeps running after its last window closes; without the
// listener the default quit would stop the run loop under later tests.
installSafeAppExit();
stopKeepAlive = keepAppAlive();
});

afterAll(() => {
stopKeepAlive();
nativeApp().quit();
});

Expand Down
42 changes: 42 additions & 0 deletions tests/unit/main/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,45 @@ describe('bootstrap native wiring', () => {
expect(seen).toBe('/Users/ada/doc.txt');
});
});

describe('bootstrap quit wiring', () => {
afterEach(() => {
setNativeAppForTesting(undefined);
app.resetForTesting();
resetBootstrapForTesting();
});

const withCountingNative = (): { quits: () => number } => {
let quits = 0;
const { native } = makeNative();
setNativeAppForTesting({
...native,
quit: () => {
quits += 1;
},
});
installSafeAppExit();
ensureNativeStarted();
return { quits: () => quits };
};

test('a will-quit veto leaves the native run loop running', () => {
const counting = withCountingNative();
const veto = (event: { preventDefault(): void }): void => {
event.preventDefault();
};
app.on('will-quit', veto);
try {
app.quit();
expect(counting.quits()).toBe(0);
} finally {
app.removeListener('will-quit', veto);
}
});

test('a completed quit stops the native run loop once', () => {
const counting = withCountingNative();
app.quit();
expect(counting.quits()).toBe(1);
});
});
4 changes: 2 additions & 2 deletions website/src/content/docs/api/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ Returns:

* `event` Event

Emitted after `before-quit` is not vetoed, immediately before the app quits. Calling `event.preventDefault()` aborts the quit. The native bootstrap also listens for this to stop the run loop before the process exits.
Emitted after `before-quit` is not vetoed, immediately before the app quits. Calling `event.preventDefault()` aborts the quit and the app keeps running, run loop included.

```ts
import { app } from 'bunmaska'
Expand All @@ -404,7 +404,7 @@ Returns:

* `event` Event - the exit code (Integer).

Emitted when the application is quitting, just before the process exits. Unlike most events, the listener receives the numeric exit code as its argument.
Emitted when the application is quitting, just before the process exits. Unlike most events, the listener receives the numeric exit code as its argument. The native bootstrap listens for this event to stop the run loop before the process exits; a vetoed quit never reaches it.

```ts
import { app } from 'bunmaska'
Expand Down
1 change: 1 addition & 0 deletions website/src/content/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A documentation-versus-reality pass: every claim in the docs was checked against

**Fixes**

- A `will-quit` (or `before-quit`) veto keeps the app alive for real: the native run loop used to be stopped on `will-quit` before the veto was honoured, so an app that cancelled its own quit stopped receiving native callbacks. The run loop now stops on `quit`, which only fires once no listener vetoed.
- `bunmaska init my-app .` works - `init` takes `[name] [dir]`, so a named project can scaffold into the current directory.
- A dev restart no longer steals focus from your editor: the respawned app comes up behind whatever is frontmost (macOS, Linux, Windows).
- `bunmaska build` honours `name`, `id` and `icon` from `bunmaska.config.ts` (flag > config > entry file name). It used to read only the flags.
Expand Down
Loading