@wong2kim/wmux 1.0.0

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.
Files changed (122) hide show
  1. package/README.md +157 -0
  2. package/assets/icon.ico +0 -0
  3. package/assets/icon.svg +6 -0
  4. package/dist/cli/cli/client.js +102 -0
  5. package/dist/cli/cli/commands/browser.js +137 -0
  6. package/dist/cli/cli/commands/input.js +80 -0
  7. package/dist/cli/cli/commands/notify.js +28 -0
  8. package/dist/cli/cli/commands/pane.js +88 -0
  9. package/dist/cli/cli/commands/surface.js +98 -0
  10. package/dist/cli/cli/commands/system.js +98 -0
  11. package/dist/cli/cli/commands/workspace.js +117 -0
  12. package/dist/cli/cli/index.js +140 -0
  13. package/dist/cli/cli/utils.js +47 -0
  14. package/dist/cli/shared/constants.js +54 -0
  15. package/dist/cli/shared/rpc.js +33 -0
  16. package/dist/cli/shared/types.js +79 -0
  17. package/dist/mcp/mcp/index.js +60 -0
  18. package/dist/mcp/mcp/wmux-client.js +146 -0
  19. package/dist/mcp/shared/constants.js +54 -0
  20. package/dist/mcp/shared/rpc.js +33 -0
  21. package/dist/mcp/shared/types.js +79 -0
  22. package/forge.config.ts +61 -0
  23. package/index.html +12 -0
  24. package/package.json +84 -0
  25. package/postcss.config.js +6 -0
  26. package/src/cli/client.ts +76 -0
  27. package/src/cli/commands/browser.ts +128 -0
  28. package/src/cli/commands/input.ts +72 -0
  29. package/src/cli/commands/notify.ts +29 -0
  30. package/src/cli/commands/pane.ts +90 -0
  31. package/src/cli/commands/surface.ts +102 -0
  32. package/src/cli/commands/system.ts +95 -0
  33. package/src/cli/commands/workspace.ts +116 -0
  34. package/src/cli/index.ts +145 -0
  35. package/src/cli/utils.ts +44 -0
  36. package/src/main/index.ts +86 -0
  37. package/src/main/ipc/handlers/clipboard.handler.ts +20 -0
  38. package/src/main/ipc/handlers/metadata.handler.ts +56 -0
  39. package/src/main/ipc/handlers/pty.handler.ts +69 -0
  40. package/src/main/ipc/handlers/session.handler.ts +17 -0
  41. package/src/main/ipc/handlers/shell.handler.ts +11 -0
  42. package/src/main/ipc/registerHandlers.ts +31 -0
  43. package/src/main/mcp/McpRegistrar.ts +156 -0
  44. package/src/main/metadata/MetadataCollector.ts +58 -0
  45. package/src/main/notification/ToastManager.ts +32 -0
  46. package/src/main/pipe/PipeServer.ts +190 -0
  47. package/src/main/pipe/RpcRouter.ts +46 -0
  48. package/src/main/pipe/handlers/_bridge.ts +40 -0
  49. package/src/main/pipe/handlers/browser.rpc.ts +132 -0
  50. package/src/main/pipe/handlers/input.rpc.ts +120 -0
  51. package/src/main/pipe/handlers/meta.rpc.ts +59 -0
  52. package/src/main/pipe/handlers/notify.rpc.ts +53 -0
  53. package/src/main/pipe/handlers/pane.rpc.ts +39 -0
  54. package/src/main/pipe/handlers/surface.rpc.ts +43 -0
  55. package/src/main/pipe/handlers/system.rpc.ts +36 -0
  56. package/src/main/pipe/handlers/workspace.rpc.ts +52 -0
  57. package/src/main/pty/AgentDetector.ts +247 -0
  58. package/src/main/pty/OscParser.ts +81 -0
  59. package/src/main/pty/PTYBridge.ts +88 -0
  60. package/src/main/pty/PTYManager.ts +104 -0
  61. package/src/main/pty/ShellDetector.ts +63 -0
  62. package/src/main/session/SessionManager.ts +53 -0
  63. package/src/main/updater/AutoUpdater.ts +132 -0
  64. package/src/main/window/createWindow.ts +71 -0
  65. package/src/mcp/README.md +56 -0
  66. package/src/mcp/index.ts +153 -0
  67. package/src/mcp/wmux-client.ts +127 -0
  68. package/src/preload/index.ts +111 -0
  69. package/src/preload/preload.ts +108 -0
  70. package/src/renderer/App.tsx +5 -0
  71. package/src/renderer/components/Browser/BrowserPanel.tsx +219 -0
  72. package/src/renderer/components/Browser/BrowserToolbar.tsx +253 -0
  73. package/src/renderer/components/Company/ApprovalDialog.tsx +3 -0
  74. package/src/renderer/components/Company/CompanyView.tsx +7 -0
  75. package/src/renderer/components/Company/MessageFeedPanel.tsx +3 -0
  76. package/src/renderer/components/Layout/AppLayout.tsx +234 -0
  77. package/src/renderer/components/Notification/NotificationPanel.tsx +129 -0
  78. package/src/renderer/components/Palette/CommandPalette.tsx +409 -0
  79. package/src/renderer/components/Palette/PaletteItem.tsx +55 -0
  80. package/src/renderer/components/Pane/Pane.tsx +122 -0
  81. package/src/renderer/components/Pane/PaneContainer.tsx +41 -0
  82. package/src/renderer/components/Pane/SurfaceTabs.tsx +46 -0
  83. package/src/renderer/components/Settings/SettingsPanel.tsx +886 -0
  84. package/src/renderer/components/Sidebar/MiniSidebar.tsx +67 -0
  85. package/src/renderer/components/Sidebar/Sidebar.tsx +84 -0
  86. package/src/renderer/components/Sidebar/WorkspaceItem.tsx +241 -0
  87. package/src/renderer/components/StatusBar/StatusBar.tsx +93 -0
  88. package/src/renderer/components/Terminal/SearchBar.tsx +126 -0
  89. package/src/renderer/components/Terminal/Terminal.tsx +102 -0
  90. package/src/renderer/components/Terminal/ViCopyMode.tsx +104 -0
  91. package/src/renderer/hooks/useKeyboard.ts +310 -0
  92. package/src/renderer/hooks/useNotificationListener.ts +80 -0
  93. package/src/renderer/hooks/useNotificationSound.ts +75 -0
  94. package/src/renderer/hooks/useRpcBridge.ts +451 -0
  95. package/src/renderer/hooks/useT.ts +11 -0
  96. package/src/renderer/hooks/useTerminal.ts +349 -0
  97. package/src/renderer/hooks/useViCopyMode.ts +320 -0
  98. package/src/renderer/i18n/index.ts +69 -0
  99. package/src/renderer/i18n/locales/en.ts +157 -0
  100. package/src/renderer/i18n/locales/ja.ts +155 -0
  101. package/src/renderer/i18n/locales/ko.ts +155 -0
  102. package/src/renderer/i18n/locales/zh.ts +155 -0
  103. package/src/renderer/index.tsx +6 -0
  104. package/src/renderer/stores/index.ts +19 -0
  105. package/src/renderer/stores/slices/notificationSlice.ts +56 -0
  106. package/src/renderer/stores/slices/paneSlice.ts +141 -0
  107. package/src/renderer/stores/slices/surfaceSlice.ts +122 -0
  108. package/src/renderer/stores/slices/uiSlice.ts +247 -0
  109. package/src/renderer/stores/slices/workspaceSlice.ts +120 -0
  110. package/src/renderer/styles/globals.css +150 -0
  111. package/src/renderer/themes.ts +99 -0
  112. package/src/shared/constants.ts +53 -0
  113. package/src/shared/electron.d.ts +11 -0
  114. package/src/shared/rpc.ts +71 -0
  115. package/src/shared/types.ts +176 -0
  116. package/tailwind.config.js +11 -0
  117. package/tsconfig.cli.json +24 -0
  118. package/tsconfig.json +21 -0
  119. package/tsconfig.mcp.json +25 -0
  120. package/vite.main.config.ts +14 -0
  121. package/vite.preload.config.ts +9 -0
  122. package/vite.renderer.config.ts +6 -0
@@ -0,0 +1,451 @@
1
+ import { useEffect } from 'react';
2
+ import { useStore } from '../stores';
3
+ import type { Pane, PaneLeaf } from '../../shared/types';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Pane tree utilities
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function findLeafPanes(root: Pane): PaneLeaf[] {
10
+ if (root.type === 'leaf') return [root];
11
+ return root.children.flatMap(findLeafPanes);
12
+ }
13
+
14
+ function findPaneById(root: Pane, id: string): Pane | null {
15
+ if (root.id === id) return root;
16
+ if (root.type === 'branch') {
17
+ for (const child of root.children) {
18
+ const found = findPaneById(child, id);
19
+ if (found) return found;
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /** Find which leaf pane contains the given surfaceId. */
26
+ function findLeafBySurfaceId(root: Pane, surfaceId: string): PaneLeaf | null {
27
+ const leaves = findLeafPanes(root);
28
+ return leaves.find((l) => l.surfaces.some((s) => s.id === surfaceId)) ?? null;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // RPC method handler type
33
+ // ---------------------------------------------------------------------------
34
+
35
+ type RpcParams = Record<string, unknown>;
36
+ type RpcResult = unknown;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Hook
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export function useRpcBridge(): void {
43
+ useEffect(() => {
44
+ // ── RPC command listener ─────────────────────────────────────────────────
45
+ const cleanupRpc = window.electronAPI.rpc.onCommand(
46
+ async (requestId: string, method: string, params: RpcParams) => {
47
+ let result: RpcResult;
48
+ try {
49
+ result = await handleRpcMethod(method, params);
50
+ } catch (err) {
51
+ result = { error: err instanceof Error ? err.message : String(err) };
52
+ }
53
+ window.electronAPI.rpc.respond(requestId, result);
54
+ },
55
+ );
56
+
57
+ return () => {
58
+ cleanupRpc();
59
+ };
60
+ }, []);
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Dispatch table
65
+ // ---------------------------------------------------------------------------
66
+
67
+ async function handleRpcMethod(method: string, params: RpcParams): Promise<RpcResult> {
68
+ // Always read the freshest state via getState() to avoid stale closures.
69
+ const store = useStore.getState();
70
+
71
+ // -------------------------------------------------------------------------
72
+ // workspace.*
73
+ // -------------------------------------------------------------------------
74
+
75
+ if (method === 'workspace.list') {
76
+ return store.workspaces.map((w) => ({ id: w.id, name: w.name }));
77
+ }
78
+
79
+ if (method === 'workspace.new') {
80
+ const name = typeof params.name === 'string' ? params.name : undefined;
81
+ store.addWorkspace(name);
82
+ // After mutation, fetch updated state.
83
+ const updated = useStore.getState();
84
+ const created = updated.workspaces.find((w) => w.id === updated.activeWorkspaceId);
85
+ return created ? { id: created.id, name: created.name } : null;
86
+ }
87
+
88
+ if (method === 'workspace.focus') {
89
+ const id = String(params.id ?? '');
90
+ store.setActiveWorkspace(id);
91
+ return { ok: true };
92
+ }
93
+
94
+ if (method === 'workspace.close') {
95
+ const id = String(params.id ?? '');
96
+ store.removeWorkspace(id);
97
+ return { ok: true };
98
+ }
99
+
100
+ if (method === 'workspace.current') {
101
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
102
+ return ws ? { id: ws.id, name: ws.name } : null;
103
+ }
104
+
105
+ // -------------------------------------------------------------------------
106
+ // surface.*
107
+ // -------------------------------------------------------------------------
108
+
109
+ if (method === 'surface.list') {
110
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
111
+ if (!ws) return [];
112
+ const activePane = findPaneById(ws.rootPane, ws.activePaneId);
113
+ if (!activePane || activePane.type !== 'leaf') return [];
114
+ return activePane.surfaces.map((s) => ({
115
+ id: s.id,
116
+ ptyId: s.ptyId,
117
+ title: s.title,
118
+ shell: s.shell,
119
+ cwd: s.cwd,
120
+ }));
121
+ }
122
+
123
+ if (method === 'surface.new') {
124
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
125
+ if (!ws) return { error: 'no active workspace' };
126
+
127
+ const paneId = ws.activePaneId;
128
+ const shell = typeof params.shell === 'string' ? params.shell : '';
129
+ const cwd = typeof params.cwd === 'string' ? params.cwd : '';
130
+
131
+ const { id: ptyId } = await window.electronAPI.pty.create({
132
+ shell: shell || undefined,
133
+ cwd: cwd || undefined,
134
+ });
135
+
136
+ // Re-read state after async gap.
137
+ store.addSurface(paneId, ptyId, shell, cwd);
138
+
139
+ const fresh = useStore.getState();
140
+ const freshWs = fresh.workspaces.find((w) => w.id === fresh.activeWorkspaceId);
141
+ if (!freshWs) return { ptyId };
142
+ const pane = findPaneById(freshWs.rootPane, paneId);
143
+ if (!pane || pane.type !== 'leaf') return { ptyId };
144
+ const surface = pane.surfaces.find((s) => s.ptyId === ptyId);
145
+ return surface
146
+ ? { id: surface.id, ptyId: surface.ptyId, title: surface.title, shell: surface.shell, cwd: surface.cwd }
147
+ : { ptyId };
148
+ }
149
+
150
+ if (method === 'surface.focus') {
151
+ const surfaceId = String(params.id ?? '');
152
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
153
+ if (!ws) return { error: 'no active workspace' };
154
+
155
+ const targetLeaf = findLeafBySurfaceId(ws.rootPane, surfaceId);
156
+ if (!targetLeaf) return { error: `surface ${surfaceId} not found` };
157
+
158
+ store.setActivePane(targetLeaf.id);
159
+ store.setActiveSurface(targetLeaf.id, surfaceId);
160
+ return { ok: true };
161
+ }
162
+
163
+ if (method === 'surface.close') {
164
+ const surfaceId = String(params.id ?? '');
165
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
166
+ if (!ws) return { error: 'no active workspace' };
167
+
168
+ const targetLeaf = findLeafBySurfaceId(ws.rootPane, surfaceId);
169
+ if (!targetLeaf) return { error: `surface ${surfaceId} not found` };
170
+
171
+ const surface = targetLeaf.surfaces.find((s) => s.id === surfaceId);
172
+ const ptyId = surface?.ptyId;
173
+
174
+ store.closeSurface(targetLeaf.id, surfaceId);
175
+
176
+ if (ptyId) {
177
+ try {
178
+ await window.electronAPI.pty.dispose(ptyId);
179
+ } catch {
180
+ // Best-effort: PTY may already be gone.
181
+ }
182
+ }
183
+
184
+ return { ok: true };
185
+ }
186
+
187
+ // -------------------------------------------------------------------------
188
+ // pane.*
189
+ // -------------------------------------------------------------------------
190
+
191
+ if (method === 'pane.list') {
192
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
193
+ if (!ws) return [];
194
+ const leaves = findLeafPanes(ws.rootPane);
195
+ return leaves.map((l) => ({
196
+ id: l.id,
197
+ surfaceCount: l.surfaces.length,
198
+ active: l.id === ws.activePaneId,
199
+ }));
200
+ }
201
+
202
+ if (method === 'pane.focus') {
203
+ const paneId = String(params.id ?? '');
204
+ store.setActivePane(paneId);
205
+ return { ok: true };
206
+ }
207
+
208
+ if (method === 'pane.split') {
209
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
210
+ if (!ws) return { error: 'no active workspace' };
211
+ const direction =
212
+ params.direction === 'vertical' ? 'vertical' : 'horizontal';
213
+ store.splitPane(ws.activePaneId, direction);
214
+ return { ok: true };
215
+ }
216
+
217
+ // -------------------------------------------------------------------------
218
+ // input.*
219
+ // -------------------------------------------------------------------------
220
+
221
+ if (method === 'input.readScreen') {
222
+ // xterm buffer access requires a ref wired in the terminal component.
223
+ // Deferred to a future implementation.
224
+ return { text: 'readScreen not yet implemented' };
225
+ }
226
+
227
+ if (method === 'input.getActivePtyId') {
228
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
229
+ if (!ws) return { ptyId: null };
230
+ const activePane = findPaneById(ws.rootPane, ws.activePaneId);
231
+ if (!activePane || activePane.type !== 'leaf') return { ptyId: null };
232
+ const surface = activePane.surfaces.find(
233
+ (s) => s.id === activePane.activeSurfaceId,
234
+ );
235
+ return { ptyId: surface?.ptyId ?? null };
236
+ }
237
+
238
+ // -------------------------------------------------------------------------
239
+ // meta.*
240
+ // -------------------------------------------------------------------------
241
+
242
+ if (method === 'meta.setStatus') {
243
+ const text = String(params.text ?? '');
244
+ store.updateWorkspaceMetadata(store.activeWorkspaceId, { status: text });
245
+ return { ok: true };
246
+ }
247
+
248
+ if (method === 'meta.setProgress') {
249
+ const value = typeof params.value === 'number' ? params.value : Number(params.value ?? 0);
250
+ store.updateWorkspaceMetadata(store.activeWorkspaceId, { progress: value });
251
+ return { ok: true };
252
+ }
253
+
254
+ // -------------------------------------------------------------------------
255
+ // browser.*
256
+ // -------------------------------------------------------------------------
257
+
258
+ if (method === 'browser.open') {
259
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
260
+ if (!ws) return { error: 'no active workspace' };
261
+ const paneId = ws.activePaneId;
262
+ const url = typeof params.url === 'string' ? params.url : undefined;
263
+ store.addBrowserSurface(paneId, url);
264
+
265
+ const fresh = useStore.getState();
266
+ const freshWs = fresh.workspaces.find((w) => w.id === fresh.activeWorkspaceId);
267
+ if (!freshWs) return { ok: true };
268
+ const pane = findPaneById(freshWs.rootPane, paneId);
269
+ if (!pane || pane.type !== 'leaf') return { ok: true };
270
+ const surface = pane.surfaces[pane.surfaces.length - 1];
271
+ return { ok: true, surfaceId: surface?.id, url: url || 'https://google.com' };
272
+ }
273
+
274
+ if (method === 'browser.snapshot') {
275
+ const surfaceId = typeof params.surfaceId === 'string' ? params.surfaceId : undefined;
276
+ return handleBrowserSnapshot(store, surfaceId);
277
+ }
278
+
279
+ if (method === 'browser.click') {
280
+ const selector = typeof params.selector === 'string' ? params.selector : '';
281
+ if (!selector) return { error: 'browser.click: missing selector' };
282
+ const surfaceId = typeof params.surfaceId === 'string' ? params.surfaceId : undefined;
283
+ return handleBrowserExec(store, `
284
+ const el = document.querySelector(${JSON.stringify(selector)});
285
+ if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
286
+ el.click();
287
+ return { ok: true, selector: ${JSON.stringify(selector)} };
288
+ `, surfaceId);
289
+ }
290
+
291
+ if (method === 'browser.fill') {
292
+ const selector = typeof params.selector === 'string' ? params.selector : '';
293
+ const text = typeof params.text === 'string' ? params.text : '';
294
+ if (!selector) return { error: 'browser.fill: missing selector' };
295
+ const surfaceId = typeof params.surfaceId === 'string' ? params.surfaceId : undefined;
296
+ return handleBrowserExec(store, `
297
+ const el = document.querySelector(${JSON.stringify(selector)});
298
+ if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
299
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
300
+ || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
301
+ if (nativeInputValueSetter) {
302
+ nativeInputValueSetter.call(el, ${JSON.stringify(text)});
303
+ el.dispatchEvent(new Event('input', { bubbles: true }));
304
+ } else {
305
+ el.value = ${JSON.stringify(text)};
306
+ }
307
+ return { ok: true };
308
+ `, surfaceId);
309
+ }
310
+
311
+ if (method === 'browser.eval') {
312
+ const code = typeof params.code === 'string' ? params.code : '';
313
+ if (!code) return { error: 'browser.eval: missing code' };
314
+ // Security: block obviously dangerous patterns that could escape
315
+ // the webview sandbox or access Electron internals.
316
+ const dangerousPatterns = [
317
+ /\brequire\s*\(/i,
318
+ /\bprocess\s*\./i,
319
+ /\b__dirname\b/i,
320
+ /\b__filename\b/i,
321
+ /\bchild_process\b/i,
322
+ /\bglobal\s*\.\s*process\b/i,
323
+ /\belectron\b/i,
324
+ ];
325
+ for (const pat of dangerousPatterns) {
326
+ if (pat.test(code)) {
327
+ return { error: 'browser.eval: code contains blocked pattern' };
328
+ }
329
+ }
330
+ const surfaceId = typeof params.surfaceId === 'string' ? params.surfaceId : undefined;
331
+ return handleBrowserExec(store, code, surfaceId);
332
+ }
333
+
334
+ if (method === 'browser.navigate') {
335
+ const url = typeof params.url === 'string' ? params.url : '';
336
+ if (!url) return { error: 'browser.navigate: missing url' };
337
+ // Security: block dangerous URL schemes that could execute code
338
+ const normalizedUrl = url.trim().toLowerCase();
339
+ if (
340
+ normalizedUrl.startsWith('javascript:') ||
341
+ normalizedUrl.startsWith('data:') ||
342
+ normalizedUrl.startsWith('vbscript:') ||
343
+ normalizedUrl.startsWith('file:')
344
+ ) {
345
+ return { error: `browser.navigate: blocked URL scheme in "${url}"` };
346
+ }
347
+ const surfaceId = typeof params.surfaceId === 'string' ? params.surfaceId : undefined;
348
+ return handleBrowserNavigate(store, url, surfaceId);
349
+ }
350
+
351
+ // -------------------------------------------------------------------------
352
+ // Unknown method
353
+ // -------------------------------------------------------------------------
354
+
355
+ return { error: `unknown method: ${method}` };
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Browser Surface helpers
360
+ // ---------------------------------------------------------------------------
361
+
362
+ /**
363
+ * Finds the active browser Surface in the given workspace state.
364
+ * Returns the surface's ptyId (used as a DOM element ID key) and the webview
365
+ * element, or an error string when nothing is found.
366
+ */
367
+ function findActiveBrowserWebview(
368
+ store: ReturnType<typeof import('../stores').useStore.getState>,
369
+ ): HTMLElement | { error: string } {
370
+ const ws = store.workspaces.find((w) => w.id === store.activeWorkspaceId);
371
+ if (!ws) return { error: 'browser: no active workspace' };
372
+
373
+ // Walk through all leaf panes and look for a browser surface.
374
+ function findLeaves(pane: import('../../shared/types').Pane): import('../../shared/types').PaneLeaf[] {
375
+ if (pane.type === 'leaf') return [pane];
376
+ return pane.children.flatMap(findLeaves);
377
+ }
378
+
379
+ const leaves = findLeaves(ws.rootPane);
380
+ for (const leaf of leaves) {
381
+ const activeSurface = leaf.surfaces.find((s) => s.id === leaf.activeSurfaceId);
382
+ if (activeSurface?.surfaceType === 'browser') {
383
+ // The Pane component renders a webview with data-surface-id attribute.
384
+ // Escape surfaceId to prevent CSS selector injection
385
+ const safeSurfaceId = CSS.escape(activeSurface.id);
386
+ const webview = document.querySelector<HTMLElement>(
387
+ `webview[data-surface-id="${safeSurfaceId}"]`,
388
+ );
389
+ if (webview) return webview;
390
+ }
391
+ }
392
+
393
+ return { error: 'browser: no active browser surface found' };
394
+ }
395
+
396
+ /**
397
+ * Finds a specific browser Surface's webview by surfaceId.
398
+ * Falls back to findActiveBrowserWebview if surfaceId is not provided.
399
+ */
400
+ function findBrowserWebviewBySurfaceId(
401
+ store: ReturnType<typeof import('../stores').useStore.getState>,
402
+ surfaceId?: string,
403
+ ): HTMLElement | { error: string } {
404
+ if (!surfaceId) return findActiveBrowserWebview(store);
405
+
406
+ const safeSurfaceId = CSS.escape(surfaceId);
407
+ const webview = document.querySelector<HTMLElement>(
408
+ `webview[data-surface-id="${safeSurfaceId}"]`,
409
+ );
410
+ if (webview) return webview;
411
+ return { error: `browser: surface ${surfaceId} not found or not a browser` };
412
+ }
413
+
414
+ async function handleBrowserSnapshot(
415
+ store: ReturnType<typeof import('../stores').useStore.getState>,
416
+ surfaceId?: string,
417
+ ): Promise<unknown> {
418
+ const webview = findBrowserWebviewBySurfaceId(store, surfaceId);
419
+ if ('error' in webview) return webview;
420
+
421
+ // Electron's <webview> exposes executeJavaScript as a method.
422
+ const wv = webview as HTMLElement & { executeJavaScript: (code: string) => Promise<unknown> };
423
+ const html = await wv.executeJavaScript('document.documentElement.outerHTML');
424
+ return { html };
425
+ }
426
+
427
+ async function handleBrowserExec(
428
+ store: ReturnType<typeof import('../stores').useStore.getState>,
429
+ code: string,
430
+ surfaceId?: string,
431
+ ): Promise<unknown> {
432
+ const webview = findBrowserWebviewBySurfaceId(store, surfaceId);
433
+ if ('error' in webview) return webview;
434
+
435
+ const wv = webview as HTMLElement & { executeJavaScript: (code: string) => Promise<unknown> };
436
+ const result = await wv.executeJavaScript(code);
437
+ return { result };
438
+ }
439
+
440
+ async function handleBrowserNavigate(
441
+ store: ReturnType<typeof import('../stores').useStore.getState>,
442
+ url: string,
443
+ surfaceId?: string,
444
+ ): Promise<unknown> {
445
+ const webview = findBrowserWebviewBySurfaceId(store, surfaceId);
446
+ if ('error' in webview) return webview;
447
+
448
+ const wv = webview as HTMLElement & { loadURL: (url: string) => Promise<void> };
449
+ await wv.loadURL(url);
450
+ return { ok: true, url };
451
+ }
@@ -0,0 +1,11 @@
1
+ import { useStore } from '../stores';
2
+ import { t } from '../i18n';
3
+
4
+ /**
5
+ * React hook that returns the `t()` translator and re-renders
6
+ * the component whenever the locale changes in the Zustand store.
7
+ */
8
+ export function useT() {
9
+ useStore((s) => s.locale); // subscribe → re-render on locale change
10
+ return t;
11
+ }