Add macOS lifecycle hooks for TypeScript apps (#314)

* Add macOS lifecycle hooks for TypeScript apps

- Expose hidden-window, fullscreen, Dock-presence, and launch-at-login controls through manifests and TypeScript effects.
- Implement AppKit and SMAppService behavior across both macOS hosts with safe cross-platform fallbacks.
- Cover the new wire/runtime paths with tests and document the menu-bar app migration workflow.

* fix: address platform lifecycle review findings

* fix: harden macOS lifecycle hooks

* fix: preserve macOS activation behavior

* fix: preserve hidden startup window state
This commit is contained in:
Chris Tate
2026-08-11 00:04:47 -05:00
committed by GitHub
parent 4c95b04539
commit 2fd7c4c3dc
41 changed files with 1071 additions and 57 deletions
+18
View File
@@ -356,6 +356,8 @@ export type CmdData =
readonly value: number;
}
| { readonly op: "window_show"; readonly label: string }
| { readonly op: "window_hide"; readonly label: string }
| { readonly op: "dock_presence"; readonly visible: boolean }
| { readonly op: "quit_app" }
| {
readonly op: "image_load";
@@ -633,6 +635,22 @@ export const Cmd = {
return { op: "window_show", label };
},
hideWindow(label: string): CmdData {
return { op: "window_hide", label };
},
setDockPresence(visible: boolean): CmdData {
return { op: "dock_presence", visible };
},
launchAtLoginStatus(route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
return Cmd.request("native-sdk.launch-at-login.status", new Uint8Array(0), route);
},
setLaunchAtLogin(enabled: boolean, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
return Cmd.request("native-sdk.launch-at-login.set", new Uint8Array([enabled ? 1 : 0]), route);
},
quitApp(): CmdData {
return { op: "quit_app" };
},
+10
View File
@@ -313,6 +313,12 @@ export type Cmd<M extends Msgish> = {
} | {
readonly op: "window_show";
readonly label: string;
} | {
readonly op: "window_hide";
readonly label: string;
} | {
readonly op: "dock_presence";
readonly visible: boolean;
} | {
readonly op: "quit_app";
} | {
@@ -405,6 +411,10 @@ export declare const Cmd: {
videoSetMuted(key: string, muted: boolean): Cmd<never>;
videoSetLoop(key: string, loop: boolean): Cmd<never>;
showWindow(label: string): Cmd<never>;
hideWindow(label: string): Cmd<never>;
setDockPresence(visible: boolean): Cmd<never>;
launchAtLoginStatus<M extends Msgish>(route: RequestRoute<M>): Cmd<M>;
setLaunchAtLogin<M extends Msgish>(enabled: boolean, route: RequestRoute<M>): Cmd<M>;
quitApp(): Cmd<never>;
imageLoad<M extends Msgish>(id: number, source: ImageSource, route: ImageRoute<M>): Cmd<M>;
imageCancel(id: number): Cmd<never>;
+31
View File
@@ -229,6 +229,10 @@
// "Open" consequence; also restores a
// minimized window. An unknown label is a
// no-op.
// Cmd.hideWindow(label) order a live window out while retaining
// its views; showWindow is the inverse.
// Cmd.setDockPresence(visible) show or remove the app from the macOS
// Dock/app switcher.
// Cmd.quitApp() graceful terminate, the tray "Quit"
// consequence: the host quits through the
// SAME shutdown path a last-window close
@@ -1048,6 +1052,8 @@ export type Cmd<M extends Msgish> =
readonly value: number;
}
| { readonly op: "window_show"; readonly label: string }
| { readonly op: "window_hide"; readonly label: string }
| { readonly op: "dock_presence"; readonly visible: boolean }
| { readonly op: "quit_app" }
| {
readonly op: "image_load";
@@ -1435,6 +1441,31 @@ export const Cmd = {
return { op: "window_show", label };
},
/// Hide a live window without closing it. Its views and native identity
/// remain intact; `showWindow` brings it back. Unknown labels no-op.
hideWindow(label: string): Cmd<never> {
return { op: "window_hide", label };
},
/// Control whether the app appears in the macOS Dock and app switcher.
/// Unsupported hosts ignore the command.
setDockPresence(visible: boolean): Cmd<never> {
return { op: "dock_presence", visible };
},
/// Query the installed bundle's launch-at-login registration. The ok
/// route receives UTF-8 bytes naming `enabled`, `disabled`,
/// `requires_approval`, or `not_found`; the err route receives a reason.
launchAtLoginStatus<M extends Msgish>(route: RequestRoute<M>): Cmd<M> {
return Cmd.request("native-sdk.launch-at-login.status", new Uint8Array(0), route);
},
/// Register or unregister the installed bundle for launch at login. The
/// ok and err payloads follow `launchAtLoginStatus`.
setLaunchAtLogin<M extends Msgish>(enabled: boolean, route: RequestRoute<M>): Cmd<M> {
return Cmd.request("native-sdk.launch-at-login.set", new Uint8Array([enabled ? 1 : 0]), route);
},
/// Quit the app for real — the graceful terminate, and the tray "Quit"
/// consequence. The host quits through the SAME shutdown path a
/// last-window close takes, so the stop hook runs exactly once and a
+4 -1
View File
@@ -3785,7 +3785,10 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "go":
if (msg.which === 0) return [model, Cmd.showWindow("player")];
if (msg.which === 1) return [model, Cmd.batch([Cmd.showWindow("player"), Cmd.quitApp()])];
if (msg.which === 1) return [model, Cmd.batch([Cmd.hideWindow("player"), Cmd.setDockPresence(false)])];
if (msg.which === 2) return [model, Cmd.launchAtLoginStatus({ key: "login-status", ok: "line", err: "failed" })];
if (msg.which === 3) return [model, Cmd.setLaunchAtLogin(true, { key: "login-set", ok: "line", err: "failed" })];
if (msg.which === 4) return [model, Cmd.batch([Cmd.showWindow("player"), Cmd.quitApp()])];
return [model, Cmd.quitApp()];
${streamTail}
`,