@workbench-kit/electron-shell 0.0.2-prototype.0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@workbench-kit/electron-shell",
3
+ "version": "0.0.2-prototype.0.2.6",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "!src/**/*.test.ts",
12
+ "!src/**/*.test.tsx",
13
+ "!src/**/*.stories.ts",
14
+ "!src/**/*.stories.tsx"
15
+ ],
16
+ "description": "Electron main-process helpers for Workbench Kit hosts (window controls, asset protocol, secret vault).",
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "tag": "prototype",
20
+ "provenance": true
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/NewChoBo/workbench-kit.git",
25
+ "directory": "packages/electron-shell"
26
+ },
27
+ "scripts": {
28
+ "test": "vitest run src",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit"
30
+ }
31
+ }
@@ -0,0 +1,123 @@
1
+ export interface SafeStorageCipher {
2
+ isEncryptionAvailable(): boolean;
3
+ encryptString(plaintext: string): Uint8Array;
4
+ decryptString(payload: Uint8Array): string;
5
+ }
6
+
7
+ export interface EncryptedSecretVault {
8
+ getSecret(id: string): Promise<string | null>;
9
+ setSecret(id: string, value: string): Promise<void>;
10
+ deleteSecret(id: string): Promise<void>;
11
+ }
12
+
13
+ export interface CreateEncryptedSecretVaultOptions {
14
+ readonly cipher: SafeStorageCipher;
15
+ readonly readVault: () => Promise<Uint8Array | null>;
16
+ readonly writeVault: (bytes: Uint8Array) => Promise<void>;
17
+ }
18
+
19
+ export class EncryptionUnavailableError extends Error {
20
+ readonly code = 'encryption_unavailable' as const;
21
+
22
+ constructor(message = 'OS-backed encryption is unavailable; refusing plaintext vault.') {
23
+ super(message);
24
+ this.name = 'EncryptionUnavailableError';
25
+ }
26
+ }
27
+
28
+ interface VaultDocument {
29
+ readonly version: 1;
30
+ readonly secrets: Record<string, string>;
31
+ }
32
+
33
+ const textEncoder = new TextEncoder();
34
+ const textDecoder = new TextDecoder();
35
+
36
+ function toBase64(bytes: Uint8Array): string {
37
+ let binary = '';
38
+ for (const byte of bytes) {
39
+ binary += String.fromCharCode(byte);
40
+ }
41
+ return btoa(binary);
42
+ }
43
+
44
+ function fromBase64(value: string): Uint8Array {
45
+ const binary = atob(value);
46
+ const bytes = new Uint8Array(binary.length);
47
+ for (let index = 0; index < binary.length; index += 1) {
48
+ bytes[index] = binary.charCodeAt(index);
49
+ }
50
+ return bytes;
51
+ }
52
+
53
+ function parseVault(bytes: Uint8Array | null): VaultDocument {
54
+ if (bytes === null || bytes.byteLength === 0) {
55
+ return { version: 1, secrets: {} };
56
+ }
57
+ const parsed = JSON.parse(textDecoder.decode(bytes)) as Partial<VaultDocument>;
58
+ if (parsed.version !== 1 || typeof parsed.secrets !== 'object' || parsed.secrets === null) {
59
+ throw new Error('Secret vault document is malformed.');
60
+ }
61
+ return { version: 1, secrets: { ...parsed.secrets } };
62
+ }
63
+
64
+ function serializeVault(document: VaultDocument): Uint8Array {
65
+ return textEncoder.encode(`${JSON.stringify(document)}\n`);
66
+ }
67
+
68
+ function assertEncryptionAvailable(cipher: SafeStorageCipher): void {
69
+ if (!cipher.isEncryptionAvailable()) {
70
+ throw new EncryptionUnavailableError();
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Opaque secret vault using an injected OS-backed cipher.
76
+ * Fails closed when encryption is unavailable (no plaintext fallback).
77
+ * Hosts own persistence via readVault/writeVault (compose with platform/node atomic write).
78
+ */
79
+ export function createEncryptedSecretVault(
80
+ options: CreateEncryptedSecretVaultOptions,
81
+ ): EncryptedSecretVault {
82
+ const { cipher, readVault, writeVault } = options;
83
+
84
+ const load = async (): Promise<VaultDocument> => {
85
+ assertEncryptionAvailable(cipher);
86
+ return parseVault(await readVault());
87
+ };
88
+
89
+ const save = async (document: VaultDocument): Promise<void> => {
90
+ assertEncryptionAvailable(cipher);
91
+ await writeVault(serializeVault(document));
92
+ };
93
+
94
+ return {
95
+ async getSecret(id: string): Promise<string | null> {
96
+ const document = await load();
97
+ const encoded = document.secrets[id];
98
+ if (typeof encoded !== 'string') {
99
+ return null;
100
+ }
101
+ return cipher.decryptString(fromBase64(encoded));
102
+ },
103
+
104
+ async setSecret(id: string, value: string): Promise<void> {
105
+ const document = await load();
106
+ const nextSecrets = {
107
+ ...document.secrets,
108
+ [id]: toBase64(cipher.encryptString(value)),
109
+ };
110
+ await save({ version: 1, secrets: nextSecrets });
111
+ },
112
+
113
+ async deleteSecret(id: string): Promise<void> {
114
+ const document = await load();
115
+ if (!(id in document.secrets)) {
116
+ return;
117
+ }
118
+ const nextSecrets = { ...document.secrets };
119
+ delete nextSecrets[id];
120
+ await save({ version: 1, secrets: nextSecrets });
121
+ },
122
+ };
123
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ export {
2
+ createEncryptedSecretVault,
3
+ EncryptionUnavailableError,
4
+ type CreateEncryptedSecretVaultOptions,
5
+ type EncryptedSecretVault,
6
+ type SafeStorageCipher,
7
+ } from './encrypted-secret-vault.js';
8
+ export {
9
+ InvalidExternalLinkUrlError,
10
+ openAllowlistedExternalLink,
11
+ UnknownExternalLinkIdError,
12
+ type ExternalLinkAllowlist,
13
+ type OpenAllowlistedExternalLinkInput,
14
+ } from './open-allowlisted-external-link.js';
15
+ export {
16
+ requireOwnedWindowForSender,
17
+ UntrustedIpcSenderError,
18
+ type IpcSenderLike,
19
+ } from './require-owned-window-for-sender.js';
20
+ export {
21
+ cacheAllowlistedHttpsAsset,
22
+ registerRootConfinedAssetProtocol,
23
+ type AssetCachePolicy,
24
+ type AssetCacheStore,
25
+ type CachedAssetMeta,
26
+ type FetchAllowlistedHttps,
27
+ type PathRootHelpers,
28
+ type PrivilegedProtocolApi,
29
+ type RegisterRootConfinedAssetProtocolOptions,
30
+ } from './root-confined-asset-protocol.js';
31
+ export {
32
+ createWin32WallpaperPathResolver,
33
+ resolveWallpaperCropRect,
34
+ type RectLike,
35
+ type SizeLike,
36
+ type WallpaperPathResolver,
37
+ } from './wallpaper-crop.js';
38
+ export {
39
+ createWindowControlsBridge,
40
+ nextMaximizedState,
41
+ registerWindowControlIpc,
42
+ type CreateWindowControlsBridgeOptions,
43
+ type RegisterWindowControlIpcOptions,
44
+ type WindowControlIpcChannels,
45
+ type WindowControlIpcMain,
46
+ type WindowControlSurface,
47
+ type WindowControlWebContents,
48
+ type WindowControlsBridge,
49
+ } from './window-controls.js';
@@ -0,0 +1,66 @@
1
+ export type ExternalLinkAllowlist = Readonly<Record<string, string>>;
2
+
3
+ export class UnknownExternalLinkIdError extends Error {
4
+ readonly code = 'unknown_external_link_id' as const;
5
+ readonly linkId: string;
6
+
7
+ constructor(linkId: string) {
8
+ super('External link id is not in the allowlist.');
9
+ this.name = 'UnknownExternalLinkIdError';
10
+ this.linkId = linkId;
11
+ }
12
+ }
13
+
14
+ export class InvalidExternalLinkUrlError extends Error {
15
+ readonly code = 'invalid_external_link_url' as const;
16
+ readonly linkId: string;
17
+
18
+ constructor(linkId: string, message = 'Allowlisted external link URL is invalid.') {
19
+ super(message);
20
+ this.name = 'InvalidExternalLinkUrlError';
21
+ this.linkId = linkId;
22
+ }
23
+ }
24
+
25
+ export interface OpenAllowlistedExternalLinkInput {
26
+ readonly linkId: string;
27
+ readonly allowlist: ExternalLinkAllowlist;
28
+ readonly openExternal: (url: string) => Promise<void>;
29
+ }
30
+
31
+ function assertHttpsUrl(linkId: string, url: string): void {
32
+ let parsed: URL;
33
+ try {
34
+ parsed = new URL(url);
35
+ } catch {
36
+ throw new InvalidExternalLinkUrlError(linkId, 'Allowlisted external link URL is not absolute.');
37
+ }
38
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
39
+ throw new InvalidExternalLinkUrlError(
40
+ linkId,
41
+ 'Allowlisted external link URL must use http: or https:.',
42
+ );
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Resolve an opaque link id through a host-injected allowlist, then open via `openExternal`.
48
+ * Pair IPC entry with `requireOwnedWindowForSender`. No product URL catalogs in kit.
49
+ */
50
+ export async function openAllowlistedExternalLink(
51
+ input: OpenAllowlistedExternalLinkInput,
52
+ ): Promise<void> {
53
+ const linkId = input.linkId.trim();
54
+ if (linkId.length === 0) {
55
+ throw new UnknownExternalLinkIdError(linkId);
56
+ }
57
+
58
+ const url = input.allowlist[linkId];
59
+ if (typeof url !== 'string' || url.trim().length === 0) {
60
+ throw new UnknownExternalLinkIdError(linkId);
61
+ }
62
+
63
+ const resolvedUrl = url.trim();
64
+ assertHttpsUrl(linkId, resolvedUrl);
65
+ await input.openExternal(resolvedUrl);
66
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Narrow IPC sender surface. Hosts adapt Electron `WebContents` (or fakes) into this shape.
3
+ * The helper never imports `electron`.
4
+ */
5
+ export interface IpcSenderLike {
6
+ readonly id?: number;
7
+ }
8
+
9
+ export class UntrustedIpcSenderError extends Error {
10
+ readonly code = 'untrusted_ipc_sender' as const;
11
+
12
+ constructor(message = 'IPC sender is not bound to an owned window.') {
13
+ super(message);
14
+ this.name = 'UntrustedIpcSenderError';
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Resolve an IPC sender to a host-owned window handle, or throw.
20
+ * Hosts own registry membership; kit owns the gate.
21
+ */
22
+ export function requireOwnedWindowForSender<TWindow>(
23
+ sender: IpcSenderLike | unknown,
24
+ resolveOwnedWindow: (sender: IpcSenderLike | unknown) => TWindow | null,
25
+ ): TWindow {
26
+ const windowHandle = resolveOwnedWindow(sender);
27
+ if (windowHandle === null) {
28
+ throw new UntrustedIpcSenderError();
29
+ }
30
+ return windowHandle;
31
+ }
@@ -0,0 +1,148 @@
1
+ export interface AssetCachePolicy {
2
+ /** Max age in ms; expired entries are treated as missing. */
3
+ readonly ttlMs: number;
4
+ /** Max bytes accepted for a single cached asset. */
5
+ readonly maxBytes: number;
6
+ }
7
+
8
+ export interface CachedAssetMeta {
9
+ readonly relativePath: string;
10
+ readonly contentType: string;
11
+ readonly fetchedAt: number;
12
+ readonly byteLength: number;
13
+ }
14
+
15
+ export interface AssetCacheStore {
16
+ readMeta(cacheKey: string): Promise<CachedAssetMeta | null>;
17
+ writeMeta(cacheKey: string, meta: CachedAssetMeta): Promise<void>;
18
+ readBytes(relativePath: string): Promise<Uint8Array | null>;
19
+ writeBytes(relativePath: string, bytes: Uint8Array): Promise<void>;
20
+ }
21
+
22
+ export interface FetchAllowlistedHttps {
23
+ (url: string): Promise<{ bytes: Uint8Array; contentType: string }>;
24
+ }
25
+
26
+ export interface PrivilegedProtocolApi {
27
+ registerSchemesAsPrivileged?: (
28
+ schemes: ReadonlyArray<{ scheme: string; privileges: Record<string, boolean> }>,
29
+ ) => void;
30
+ handle: (
31
+ scheme: string,
32
+ handler: (request: { url: string }) => Promise<{ data: Uint8Array; mimeType: string }>,
33
+ ) => void;
34
+ }
35
+
36
+ export interface PathRootHelpers {
37
+ /** Resolve a relative key under the cache root; must reject escapes. */
38
+ readonly resolveInsideRoot: (root: string, relativePath: string) => string;
39
+ }
40
+
41
+ export interface RegisterRootConfinedAssetProtocolOptions extends PathRootHelpers {
42
+ readonly scheme: string;
43
+ readonly cacheRoot: string;
44
+ readonly protocol: PrivilegedProtocolApi;
45
+ readonly cache: AssetCacheStore;
46
+ readonly policy: AssetCachePolicy;
47
+ readonly now?: () => number;
48
+ }
49
+
50
+ function relativeAssetPath(cacheKey: string): string {
51
+ return `objects/${cacheKey}.bin`;
52
+ }
53
+
54
+ function cacheKeyFromProtocolUrl(requestUrl: string): string {
55
+ const url = new URL(requestUrl);
56
+ const fromHost = url.hostname.trim();
57
+ if (fromHost.length > 0) {
58
+ return decodeURIComponent(fromHost);
59
+ }
60
+ return decodeURIComponent(url.pathname.replace(/^\//, ''));
61
+ }
62
+
63
+ /**
64
+ * Populate the root-confined cache from an allowlisted HTTPS response.
65
+ * Hosts own which URLs are fetched and how hash/TTL/size policy is chosen.
66
+ * Inject `resolveInsideRoot` from `@workbench-kit/platform/node` (or a test fake).
67
+ */
68
+ export async function cacheAllowlistedHttpsAsset(
69
+ options: PathRootHelpers & {
70
+ readonly url: string;
71
+ readonly cacheRoot: string;
72
+ readonly cache: AssetCacheStore;
73
+ readonly policy: AssetCachePolicy;
74
+ readonly hashCacheKey: (url: string) => string;
75
+ readonly fetchHttps: FetchAllowlistedHttps;
76
+ readonly now?: () => number;
77
+ },
78
+ ): Promise<CachedAssetMeta> {
79
+ const cacheKey = options.hashCacheKey(options.url);
80
+ const relativePath = relativeAssetPath(cacheKey);
81
+ options.resolveInsideRoot(options.cacheRoot, relativePath);
82
+
83
+ const response = await options.fetchHttps(options.url);
84
+ if (response.bytes.byteLength > options.policy.maxBytes) {
85
+ throw new Error('Cached asset exceeds the configured maxBytes limit.');
86
+ }
87
+
88
+ await options.cache.writeBytes(relativePath, response.bytes);
89
+ const meta: CachedAssetMeta = {
90
+ relativePath,
91
+ contentType: response.contentType || 'application/octet-stream',
92
+ fetchedAt: (options.now ?? Date.now)(),
93
+ byteLength: response.bytes.byteLength,
94
+ };
95
+ await options.cache.writeMeta(cacheKey, meta);
96
+ return meta;
97
+ }
98
+
99
+ /**
100
+ * Register a privileged custom protocol that serves only files under `cacheRoot`.
101
+ * Unknown / expired cache keys reject; path escapes are rejected by `resolveInsideRoot`.
102
+ */
103
+ export function registerRootConfinedAssetProtocol(
104
+ options: RegisterRootConfinedAssetProtocolOptions,
105
+ ): void {
106
+ const { scheme, cacheRoot, protocol, cache, policy, resolveInsideRoot } = options;
107
+ const now = options.now ?? Date.now;
108
+
109
+ protocol.registerSchemesAsPrivileged?.([
110
+ {
111
+ scheme,
112
+ privileges: {
113
+ standard: true,
114
+ secure: true,
115
+ supportFetchAPI: true,
116
+ corsEnabled: true,
117
+ stream: true,
118
+ },
119
+ },
120
+ ]);
121
+
122
+ protocol.handle(scheme, async (request) => {
123
+ const cacheKey = cacheKeyFromProtocolUrl(request.url);
124
+ if (!cacheKey) {
125
+ throw new Error('Asset protocol request is missing a cache key.');
126
+ }
127
+
128
+ const meta = await cache.readMeta(cacheKey);
129
+ if (meta === null) {
130
+ throw new Error('Cached asset is not present.');
131
+ }
132
+ if (now() - meta.fetchedAt > policy.ttlMs) {
133
+ throw new Error('Cached asset has expired.');
134
+ }
135
+
136
+ resolveInsideRoot(cacheRoot, meta.relativePath);
137
+
138
+ const bytes = await cache.readBytes(meta.relativePath);
139
+ if (bytes === null) {
140
+ throw new Error('Cached asset bytes are missing.');
141
+ }
142
+
143
+ return {
144
+ data: bytes,
145
+ mimeType: meta.contentType,
146
+ };
147
+ });
148
+ }
@@ -0,0 +1,83 @@
1
+ export interface SizeLike {
2
+ readonly width: number;
3
+ readonly height: number;
4
+ }
5
+
6
+ export interface RectLike {
7
+ readonly x: number;
8
+ readonly y: number;
9
+ readonly width: number;
10
+ readonly height: number;
11
+ }
12
+
13
+ /**
14
+ * Compute the source crop rectangle on a wallpaper image for a monitor when the
15
+ * desktop wallpaper is spanned across the virtual desktop.
16
+ *
17
+ * Maps monitor bounds from virtual-desktop coordinates into image pixel space
18
+ * using the image's cover of the full virtual desktop (uniform scale, centered).
19
+ */
20
+ export function resolveWallpaperCropRect(
21
+ imageSize: SizeLike,
22
+ virtualDesktop: RectLike,
23
+ monitor: RectLike,
24
+ ): RectLike {
25
+ if (imageSize.width <= 0 || imageSize.height <= 0) {
26
+ throw new Error('Wallpaper image size must be positive.');
27
+ }
28
+ if (virtualDesktop.width <= 0 || virtualDesktop.height <= 0) {
29
+ throw new Error('Virtual desktop size must be positive.');
30
+ }
31
+ if (monitor.width <= 0 || monitor.height <= 0) {
32
+ throw new Error('Monitor size must be positive.');
33
+ }
34
+
35
+ const scale = Math.max(
36
+ virtualDesktop.width / imageSize.width,
37
+ virtualDesktop.height / imageSize.height,
38
+ );
39
+ const drawnWidth = imageSize.width * scale;
40
+ const drawnHeight = imageSize.height * scale;
41
+ const offsetX = virtualDesktop.x - (drawnWidth - virtualDesktop.width) / 2;
42
+ const offsetY = virtualDesktop.y - (drawnHeight - virtualDesktop.height) / 2;
43
+
44
+ const cropX = (monitor.x - offsetX) / scale;
45
+ const cropY = (monitor.y - offsetY) / scale;
46
+ const cropWidth = monitor.width / scale;
47
+ const cropHeight = monitor.height / scale;
48
+
49
+ const x = Math.max(0, Math.min(imageSize.width, cropX));
50
+ const y = Math.max(0, Math.min(imageSize.height, cropY));
51
+ const maxWidth = imageSize.width - x;
52
+ const maxHeight = imageSize.height - y;
53
+
54
+ return {
55
+ x: Math.round(x),
56
+ y: Math.round(y),
57
+ width: Math.max(0, Math.round(Math.min(cropWidth, maxWidth))),
58
+ height: Math.max(0, Math.round(Math.min(cropHeight, maxHeight))),
59
+ };
60
+ }
61
+
62
+ export interface WallpaperPathResolver {
63
+ resolveWallpaperPath(): Promise<string | null>;
64
+ }
65
+
66
+ /**
67
+ * Win32 wallpaper path resolver behind an injected registry reader.
68
+ * Other platforms should inject a resolver that returns null until implemented.
69
+ */
70
+ export function createWin32WallpaperPathResolver(options: {
71
+ readonly readRegistryString: (keyPath: string, valueName: string) => Promise<string | null>;
72
+ }): WallpaperPathResolver {
73
+ return {
74
+ async resolveWallpaperPath(): Promise<string | null> {
75
+ const value = await options.readRegistryString('HKCU\\Control Panel\\Desktop', 'WallPaper');
76
+ if (value === null) {
77
+ return null;
78
+ }
79
+ const trimmed = value.trim();
80
+ return trimmed.length > 0 ? trimmed : null;
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,150 @@
1
+ import { requireOwnedWindowForSender } from './require-owned-window-for-sender.js';
2
+
3
+ export interface WindowControlSurface {
4
+ minimize(): void;
5
+ maximize(): void;
6
+ unmaximize(): void;
7
+ close(): void;
8
+ isMaximized(): boolean;
9
+ onMaximizedChange?(listener: (maximized: boolean) => void): () => void;
10
+ }
11
+
12
+ export interface WindowControlIpcChannels {
13
+ readonly minimize: string;
14
+ readonly toggleMaximized: string;
15
+ readonly close: string;
16
+ readonly isMaximized: string;
17
+ readonly maximizedChanged: string;
18
+ }
19
+
20
+ /** Narrow ipcMain surface — hosts inject Electron `ipcMain` or a fake. */
21
+ export interface WindowControlIpcMain {
22
+ handle(
23
+ channel: string,
24
+ listener: (event: { sender: unknown }, ...args: unknown[]) => unknown,
25
+ ): void;
26
+ }
27
+
28
+ /** Narrow webContents surface for push events. */
29
+ export interface WindowControlWebContents {
30
+ send(channel: string, ...args: unknown[]): void;
31
+ }
32
+
33
+ export interface RegisterWindowControlIpcOptions {
34
+ readonly ipcMain: WindowControlIpcMain;
35
+ readonly channels: WindowControlIpcChannels;
36
+ readonly resolveWindow: (sender: unknown) => WindowControlSurface | null;
37
+ /**
38
+ * Optional: push maximized-changed to the sender webContents.
39
+ * When omitted, `onMaximizedChange` on the window surface is still subscribed if present,
40
+ * but no IPC push is sent.
41
+ */
42
+ readonly resolveWebContents?: (sender: unknown) => WindowControlWebContents | null;
43
+ }
44
+
45
+ export interface WindowControlsBridge {
46
+ minimize(): Promise<void>;
47
+ toggleMaximized(): Promise<void>;
48
+ close(): Promise<void>;
49
+ isMaximized(): Promise<boolean>;
50
+ onMaximizedChanged(listener: (maximized: boolean) => void): () => void;
51
+ }
52
+
53
+ export interface CreateWindowControlsBridgeOptions {
54
+ readonly channels: WindowControlIpcChannels;
55
+ readonly invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
56
+ readonly subscribe: (channel: string, listener: (...args: unknown[]) => void) => () => void;
57
+ }
58
+
59
+ /** Pure helper: next maximized state after a toggle. */
60
+ export function nextMaximizedState(isMaximized: boolean): boolean {
61
+ return !isMaximized;
62
+ }
63
+
64
+ /**
65
+ * Register frameless window-control IPC handlers on an injected ipcMain.
66
+ * Hosts inject channel names and resolve owned windows (pair with sender gate).
67
+ */
68
+ export function registerWindowControlIpc(options: RegisterWindowControlIpcOptions): () => void {
69
+ const { ipcMain, channels, resolveWindow, resolveWebContents } = options;
70
+ const unsubscribers = new Map<unknown, () => void>();
71
+
72
+ const resolveOwned = (sender: unknown): WindowControlSurface =>
73
+ requireOwnedWindowForSender(sender, resolveWindow);
74
+
75
+ const ensureMaximizedPush = (sender: unknown, windowSurface: WindowControlSurface): void => {
76
+ if (!windowSurface.onMaximizedChange || unsubscribers.has(sender)) {
77
+ return;
78
+ }
79
+ const unsubscribe = windowSurface.onMaximizedChange((maximized) => {
80
+ const webContents = resolveWebContents?.(sender) ?? null;
81
+ webContents?.send(channels.maximizedChanged, maximized);
82
+ });
83
+ unsubscribers.set(sender, unsubscribe);
84
+ };
85
+
86
+ ipcMain.handle(channels.minimize, (event) => {
87
+ const windowSurface = resolveOwned(event.sender);
88
+ ensureMaximizedPush(event.sender, windowSurface);
89
+ windowSurface.minimize();
90
+ });
91
+
92
+ ipcMain.handle(channels.toggleMaximized, (event) => {
93
+ const windowSurface = resolveOwned(event.sender);
94
+ ensureMaximizedPush(event.sender, windowSurface);
95
+ if (windowSurface.isMaximized()) {
96
+ windowSurface.unmaximize();
97
+ } else {
98
+ windowSurface.maximize();
99
+ }
100
+ return windowSurface.isMaximized();
101
+ });
102
+
103
+ ipcMain.handle(channels.close, (event) => {
104
+ const windowSurface = resolveOwned(event.sender);
105
+ windowSurface.close();
106
+ });
107
+
108
+ ipcMain.handle(channels.isMaximized, (event) => {
109
+ const windowSurface = resolveOwned(event.sender);
110
+ ensureMaximizedPush(event.sender, windowSurface);
111
+ return windowSurface.isMaximized();
112
+ });
113
+
114
+ return () => {
115
+ for (const unsubscribe of unsubscribers.values()) {
116
+ unsubscribe();
117
+ }
118
+ unsubscribers.clear();
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Preload/renderer bridge factory for window controls.
124
+ * Channel names are injected; kit owns the invoke/subscribe shape.
125
+ */
126
+ export function createWindowControlsBridge(
127
+ options: CreateWindowControlsBridgeOptions,
128
+ ): WindowControlsBridge {
129
+ const { channels, invoke, subscribe } = options;
130
+
131
+ return {
132
+ minimize: async () => {
133
+ await invoke(channels.minimize);
134
+ },
135
+ toggleMaximized: async () => {
136
+ await invoke(channels.toggleMaximized);
137
+ },
138
+ close: async () => {
139
+ await invoke(channels.close);
140
+ },
141
+ isMaximized: async () => {
142
+ const value = await invoke(channels.isMaximized);
143
+ return Boolean(value);
144
+ },
145
+ onMaximizedChanged: (listener) =>
146
+ subscribe(channels.maximizedChanged, (maximized) => {
147
+ listener(Boolean(maximized));
148
+ }),
149
+ };
150
+ }