Skip to content

Commit e2d5cd6

Browse files
committed
feat: route macOS Dock-reopen to the app activate event via an NSApplicationDelegate
1 parent 6d25f8b commit e2d5cd6

7 files changed

Lines changed: 177 additions & 0 deletions

File tree

src/main/api/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class App extends EventEmitter {
5959
this.#singleInstance = undefined;
6060
this.#pathOverrides.clear();
6161
for (const event of [
62+
'activate',
6263
'window-all-closed',
6364
'browser-window-created',
6465
'browser-window-focus',

src/main/bootstrap.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { makeCancelableEvent } from '../common/cancelable-event';
12
import { app } from './api/app';
23
import { nativeApp } from './native-app';
34

@@ -20,6 +21,10 @@ export const ensureNativeStarted = (): void => {
2021
started = true;
2122
const native = nativeApp();
2223
native.onReady(() => app.markReady());
24+
// macOS Dock-reopen → Electron's `activate` (Linux backends omit onActivate).
25+
native.onActivate?.((hasVisibleWindows) => {
26+
app.emit('activate', makeCancelableEvent(), hasVisibleWindows);
27+
});
2328
native.start();
2429
};
2530

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { cocoa } from './cocoa-runtime';
2+
import { defineObjcClass } from './cocoa-runtime-class';
3+
import type { Handle } from './objc';
4+
5+
/**
6+
* Bridges `NSApplicationDelegate` callbacks to JS (D026).
7+
*
8+
* AppKit reports app-level activation to the application's delegate. We define
9+
* the delegate class once at runtime, instantiate one, and route its callbacks
10+
* to the registered JS handlers — the same mechanism proven for the window and
11+
* navigation delegates.
12+
*
13+
* `applicationShouldHandleReopen:hasVisibleWindows:` is AppKit's Dock-reopen
14+
* hook and the source of Electron's `activate` event. The delegate object is
15+
* created with `alloc`/`init` (retain count +1) and never released, so it
16+
* outlives `NSApp` (which holds its delegate weakly).
17+
*/
18+
19+
/** JS handlers an `NSApplicationDelegate` instance routes callbacks to. */
20+
export type AppDelegateHandlers = {
21+
/** The app was re-activated; `hasVisibleWindows` is AppKit's flag. */
22+
readonly activate: (hasVisibleWindows: boolean) => void;
23+
};
24+
25+
let delegateClass: Handle | undefined;
26+
let current: AppDelegateHandlers | undefined;
27+
28+
const ensureDelegateClass = (): Handle => {
29+
if (delegateClass !== undefined) {
30+
return delegateClass;
31+
}
32+
delegateClass = defineObjcClass('SambarAppDelegate', 'NSObject', [
33+
{
34+
// BOOL applicationShouldHandleReopen:(id)sender hasVisibleWindows:(BOOL)flag
35+
selector: 'applicationShouldHandleReopen:hasVisibleWindows:',
36+
typeEncoding: 'c@:@c',
37+
args: ['object', 'object'],
38+
returns: 'bool',
39+
impl: (_self, _cmd, _sender, hasVisibleWindows) => {
40+
current?.activate(hasVisibleWindows === 1n);
41+
// Return YES so AppKit performs its default reopen behavior.
42+
return 1;
43+
},
44+
},
45+
]);
46+
return delegateClass;
47+
};
48+
49+
/** The Objective-C delegate instance to pass to `[NSApp setDelegate:]`. */
50+
export type AppDelegate = {
51+
readonly handle: Handle;
52+
};
53+
54+
/**
55+
* Create an `NSApplicationDelegate` instance routing callbacks to `handlers`.
56+
* There is one application delegate per process; the most recent handlers win.
57+
*/
58+
export const createAppDelegate = (handlers: AppDelegateHandlers): AppDelegate => {
59+
const rt = cocoa();
60+
const cls = ensureDelegateClass();
61+
current = handlers;
62+
const handle = rt.msgSend(rt.msgSend(cls, rt.selectors.get('alloc')), rt.selectors.get('init'));
63+
return { handle };
64+
};

src/main/platform/macos/cocoa-backend.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
msgSendSize,
3232
msgSendU8,
3333
} from './cocoa-msgsend-variants';
34+
import { createAppDelegate } from './cocoa-app-delegate';
3435
import { createMacOSDrain } from './cocoa-run-loop';
3536
import { cocoa } from './cocoa-runtime';
3637
import { createNavigationDelegate } from './cocoa-navigation-delegate';
@@ -462,8 +463,10 @@ class MacOSWindow implements NativeWindow {
462463
class MacOSApplication implements NativeApplication {
463464
#started = false;
464465
#app: Handle = 0n;
466+
#appDelegate: Handle = 0n;
465467
#pump: CooperativePump | undefined;
466468
#readyCallbacks: Array<() => void> = [];
469+
#onActivate: ((hasVisibleWindows: boolean) => void) | undefined;
467470

468471
start(): void {
469472
if (this.#started) {
@@ -472,6 +475,13 @@ class MacOSApplication implements NativeApplication {
472475
const rt = cocoa();
473476
loadWebKit();
474477
this.#app = rt.msgSend(rt.classes.get('NSApplication'), rt.selectors.get('sharedApplication'));
478+
// Install the application delegate (Dock-reopen → activate). NSApp holds its
479+
// delegate weakly, so the +1 from alloc/init (never released) keeps it alive.
480+
const delegate = createAppDelegate({
481+
activate: (hasVisibleWindows) => this.#onActivate?.(hasVisibleWindows),
482+
});
483+
this.#appDelegate = delegate.handle;
484+
msgSendPtr(this.#app, rt.selectors.get('setDelegate:'), this.#appDelegate);
475485
msgSendI64(this.#app, rt.selectors.get('setActivationPolicy:'), NS_ACTIVATION_POLICY_REGULAR);
476486
rt.msgSend(this.#app, rt.selectors.get('finishLaunching'));
477487
msgSendU8(this.#app, rt.selectors.get('activateIgnoringOtherApps:'), 1);
@@ -496,6 +506,10 @@ class MacOSApplication implements NativeApplication {
496506
}
497507
}
498508

509+
onActivate(callback: (hasVisibleWindows: boolean) => void): void {
510+
this.#onActivate = callback;
511+
}
512+
499513
createWindow(options: NativeWindowOptions): NativeWindow {
500514
const rt = cocoa();
501515
const frame: readonly [number, number, number, number] = [0, 0, options.width, options.height];

src/main/platform/native.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,11 @@ export interface NativeApplication {
141141
createWindow(options: NativeWindowOptions): NativeWindow;
142142
/** Stop the run loop and release the application. */
143143
quit(): void;
144+
/**
145+
* Register a callback fired when the app is re-activated (Electron's `activate`
146+
* — e.g. a macOS Dock-icon click), receiving whether any windows are visible.
147+
* Optional: platforms without an activation concept (Linux) omit it. Must be
148+
* registered before {@link start}.
149+
*/
150+
onActivate?(callback: (hasVisibleWindows: boolean) => void): void;
144151
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { currentPlatform } from '../../../src/common/platform';
3+
import { createAppDelegate } from '../../../src/main/platform/macos/cocoa-app-delegate';
4+
import { msgSendPtr } from '../../../src/main/platform/macos/cocoa-msgsend-variants';
5+
import { cocoa } from '../../../src/main/platform/macos/cocoa-runtime';
6+
7+
/**
8+
* Drives the runtime `SambarAppDelegate` against the real Objective-C runtime.
9+
* The Dock-reopen callback routing uses the same `defineObjcClass` JSCallback
10+
* mechanism as the (CI-proven) window/navigation delegates; here we prove the
11+
* class builds, instantiates, and installs as `NSApp`'s delegate.
12+
*/
13+
14+
if (currentPlatform() === 'macos') {
15+
describe('SambarAppDelegate on the real macOS runtime', () => {
16+
test('createAppDelegate returns a live instance', () => {
17+
const delegate = createAppDelegate({ activate: () => undefined });
18+
expect(delegate.handle).not.toBe(0n);
19+
});
20+
21+
test('installs on NSApp and reads back via -delegate', () => {
22+
const rt = cocoa();
23+
const nsApp = rt.msgSend(
24+
rt.classes.get('NSApplication'),
25+
rt.selectors.get('sharedApplication'),
26+
);
27+
const delegate = createAppDelegate({ activate: () => undefined });
28+
msgSendPtr(nsApp, rt.selectors.get('setDelegate:'), delegate.handle);
29+
expect(rt.msgSend(nsApp, rt.selectors.get('delegate'))).toBe(delegate.handle);
30+
});
31+
});
32+
}

tests/unit/main/bootstrap.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { afterEach, describe, expect, test } from 'bun:test';
2+
import { app } from '../../../src/main/api/app';
3+
import { ensureNativeStarted, resetBootstrapForTesting } from '../../../src/main/bootstrap';
4+
import { setNativeAppForTesting } from '../../../src/main/native-app';
5+
import type { NativeApplication } from '../../../src/main/platform/native';
6+
import { installSafeAppExit } from '../../helpers/safe-app-exit';
7+
8+
/** A fake native app exposing a trigger for its registered activate callback. */
9+
const makeNative = (): { native: NativeApplication; activate: (v: boolean) => void } => {
10+
let cb: ((v: boolean) => void) | undefined;
11+
const native: NativeApplication = {
12+
start: () => undefined,
13+
onReady: (ready) => ready(),
14+
createWindow: () => {
15+
throw new Error('createWindow not used in bootstrap tests');
16+
},
17+
quit: () => undefined,
18+
onActivate: (c) => {
19+
cb = c;
20+
},
21+
};
22+
return { native, activate: (v) => cb?.(v) };
23+
};
24+
25+
describe('bootstrap native wiring', () => {
26+
afterEach(() => {
27+
setNativeAppForTesting(undefined);
28+
app.resetForTesting();
29+
resetBootstrapForTesting();
30+
});
31+
32+
test('forwards native activate to the app activate event with hasVisibleWindows', () => {
33+
installSafeAppExit();
34+
const { native, activate } = makeNative();
35+
resetBootstrapForTesting();
36+
setNativeAppForTesting(native);
37+
ensureNativeStarted();
38+
let seen: boolean | undefined;
39+
app.on('activate', (_event: unknown, hasVisibleWindows: boolean) => {
40+
seen = hasVisibleWindows;
41+
});
42+
activate(true);
43+
expect(seen).toBe(true);
44+
});
45+
46+
test('marks the app ready once the native app signals ready', () => {
47+
installSafeAppExit();
48+
const { native } = makeNative();
49+
resetBootstrapForTesting();
50+
setNativeAppForTesting(native);
51+
ensureNativeStarted();
52+
expect(app.isReady).toBe(true);
53+
});
54+
});

0 commit comments

Comments
 (0)