@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,127 @@
1
+ import * as net from 'net';
2
+ import * as fs from 'fs';
3
+ import * as crypto from 'crypto';
4
+ import type { RpcMethod, RpcResponse } from '../shared/rpc';
5
+ import { getPipeName, getAuthTokenPath } from '../shared/constants';
6
+
7
+ const TIMEOUT_MS = 10000;
8
+ const RETRY_COUNT = 3;
9
+ const RETRY_DELAY_MS = 1000;
10
+
11
+ function readAuthToken(): string | undefined {
12
+ // Env var takes priority (when running inside wmux terminal)
13
+ if (process.env.WMUX_AUTH_TOKEN) return process.env.WMUX_AUTH_TOKEN;
14
+ // File fallback (when spawned by Claude Code as MCP server)
15
+ try {
16
+ return fs.readFileSync(getAuthTokenPath(), 'utf8').trim();
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ function attemptRpc(
23
+ pipePath: string,
24
+ token: string,
25
+ method: RpcMethod,
26
+ params: Record<string, unknown>,
27
+ ): Promise<unknown> {
28
+ return new Promise((resolve, reject) => {
29
+ const id = crypto.randomUUID();
30
+ const request = JSON.stringify({ id, method, params, token }) + '\n';
31
+
32
+ const socket = net.connect(pipePath);
33
+ let buffer = '';
34
+ let settled = false;
35
+
36
+ const timer = setTimeout(() => {
37
+ if (!settled) {
38
+ settled = true;
39
+ socket.destroy();
40
+ reject(new Error(`RPC timeout: ${method} (${TIMEOUT_MS}ms)`));
41
+ }
42
+ }, TIMEOUT_MS);
43
+
44
+ socket.on('connect', () => {
45
+ socket.write(request);
46
+ });
47
+
48
+ socket.on('data', (chunk: Buffer) => {
49
+ buffer += chunk.toString('utf8');
50
+ const lines = buffer.split('\n');
51
+ buffer = lines.pop() ?? '';
52
+
53
+ for (const line of lines) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed) continue;
56
+ try {
57
+ const response = JSON.parse(trimmed) as RpcResponse;
58
+ if (response.id === id && !settled) {
59
+ settled = true;
60
+ clearTimeout(timer);
61
+ socket.destroy();
62
+ if (response.ok) {
63
+ resolve(response.result);
64
+ } else {
65
+ reject(new Error(response.error));
66
+ }
67
+ }
68
+ } catch {
69
+ // ignore malformed lines
70
+ }
71
+ }
72
+ });
73
+
74
+ socket.on('error', (err: NodeJS.ErrnoException) => {
75
+ if (!settled) {
76
+ settled = true;
77
+ clearTimeout(timer);
78
+ if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') {
79
+ reject(new Error('wmux is not running. Start the app first.'));
80
+ } else {
81
+ reject(new Error(`Connection error: ${err.message}`));
82
+ }
83
+ }
84
+ });
85
+
86
+ socket.on('close', () => {
87
+ if (!settled) {
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ reject(new Error('Connection closed before response was received.'));
91
+ }
92
+ });
93
+ });
94
+ }
95
+
96
+ function sleep(ms: number): Promise<void> {
97
+ return new Promise((r) => setTimeout(r, ms));
98
+ }
99
+
100
+ export async function sendRpc(
101
+ method: RpcMethod,
102
+ params: Record<string, unknown> = {},
103
+ ): Promise<unknown> {
104
+ const pipePath = process.env.WMUX_SOCKET_PATH || getPipeName();
105
+
106
+ for (let attempt = 0; attempt < RETRY_COUNT; attempt++) {
107
+ // Re-read token on every attempt (wmux may have restarted with new token)
108
+ const token = readAuthToken();
109
+ if (!token) {
110
+ throw new Error('wmux auth token not found. Is wmux running?');
111
+ }
112
+
113
+ try {
114
+ return await attemptRpc(pipePath, token, method, params);
115
+ } catch (err) {
116
+ const msg = (err as Error).message;
117
+ const isRetryable = msg.includes('not running') || msg.includes('unauthorized');
118
+ if (isRetryable && attempt < RETRY_COUNT - 1) {
119
+ await sleep(RETRY_DELAY_MS);
120
+ continue;
121
+ }
122
+ throw err;
123
+ }
124
+ }
125
+
126
+ throw new Error('wmux is not running. Start the app first.');
127
+ }
@@ -0,0 +1,111 @@
1
+ import { contextBridge, ipcRenderer } from 'electron';
2
+ import { IPC } from '../shared/constants';
3
+
4
+ const electronAPI = {
5
+ pty: {
6
+ create: (options?: { shell?: string; cwd?: string; cols?: number; rows?: number }) =>
7
+ ipcRenderer.invoke(IPC.PTY_CREATE, options),
8
+ write: (id: string, data: string) =>
9
+ ipcRenderer.invoke(IPC.PTY_WRITE, id, data),
10
+ resize: (id: string, cols: number, rows: number) =>
11
+ ipcRenderer.invoke(IPC.PTY_RESIZE, id, cols, rows),
12
+ dispose: (id: string) =>
13
+ ipcRenderer.invoke(IPC.PTY_DISPOSE, id),
14
+ onData: (callback: (id: string, data: string) => void) => {
15
+ const listener = (_event: Electron.IpcRendererEvent, id: string, data: string) => callback(id, data);
16
+ ipcRenderer.on(IPC.PTY_DATA, listener);
17
+ return () => { ipcRenderer.removeListener(IPC.PTY_DATA, listener); };
18
+ },
19
+ onExit: (callback: (id: string, exitCode: number) => void) => {
20
+ const listener = (_event: Electron.IpcRendererEvent, id: string, exitCode: number) => callback(id, exitCode);
21
+ ipcRenderer.on(IPC.PTY_EXIT, listener);
22
+ return () => { ipcRenderer.removeListener(IPC.PTY_EXIT, listener); };
23
+ },
24
+ },
25
+ shell: {
26
+ list: () => ipcRenderer.invoke(IPC.SHELL_LIST) as Promise<{ name: string; path: string; args?: string[] }[]>,
27
+ },
28
+ session: {
29
+ save: (data: unknown) => ipcRenderer.invoke(IPC.SESSION_SAVE, data),
30
+ load: () => ipcRenderer.invoke(IPC.SESSION_LOAD),
31
+ },
32
+ settings: {
33
+ setToastEnabled: (enabled: boolean) => ipcRenderer.send(IPC.TOAST_ENABLED, enabled),
34
+ },
35
+ notification: {
36
+ onNew: (callback: (ptyId: string, data: { type: string; title: string; body: string }) => void) => {
37
+ const listener = (_event: Electron.IpcRendererEvent, ptyId: string, data: { type: string; title: string; body: string }) =>
38
+ callback(ptyId, data);
39
+ ipcRenderer.on(IPC.NOTIFICATION, listener);
40
+ return () => { ipcRenderer.removeListener(IPC.NOTIFICATION, listener); };
41
+ },
42
+ onCwdChanged: (callback: (ptyId: string, cwd: string) => void) => {
43
+ const listener = (_event: Electron.IpcRendererEvent, ptyId: string, cwd: string) =>
44
+ callback(ptyId, cwd);
45
+ ipcRenderer.on(IPC.CWD_CHANGED, listener);
46
+ return () => { ipcRenderer.removeListener(IPC.CWD_CHANGED, listener); };
47
+ },
48
+ },
49
+ metadata: {
50
+ request: (ptyId: string) =>
51
+ ipcRenderer.invoke(IPC.METADATA_REQUEST, ptyId),
52
+ onUpdate: (callback: (ptyId: string, data: { gitBranch?: string; cwd?: string; listeningPorts?: number[] }) => void) => {
53
+ const listener = (_event: Electron.IpcRendererEvent, ptyId: string, data: { gitBranch?: string; cwd?: string; listeningPorts?: number[] }) =>
54
+ callback(ptyId, data);
55
+ ipcRenderer.on(IPC.METADATA_UPDATE, listener);
56
+ return () => { ipcRenderer.removeListener(IPC.METADATA_UPDATE, listener); };
57
+ },
58
+ },
59
+ rpc: {
60
+ onCommand: (
61
+ callback: (requestId: string, method: string, params: Record<string, unknown>) => void,
62
+ ) => {
63
+ const listener = (
64
+ _event: Electron.IpcRendererEvent,
65
+ requestId: string,
66
+ method: string,
67
+ params: Record<string, unknown>,
68
+ ) => callback(requestId, method, params);
69
+ ipcRenderer.on(IPC.RPC_COMMAND, listener);
70
+ return () => { ipcRenderer.removeListener(IPC.RPC_COMMAND, listener); };
71
+ },
72
+ respond: (requestId: string, result: unknown) =>
73
+ ipcRenderer.send(`${IPC.RPC_RESPONSE}:${requestId}`, result),
74
+ },
75
+ updater: {
76
+ checkForUpdates: () =>
77
+ ipcRenderer.invoke(IPC.UPDATE_CHECK) as Promise<{ status: string }>,
78
+ installUpdate: () =>
79
+ ipcRenderer.invoke(IPC.UPDATE_INSTALL),
80
+ onUpdateAvailable: (callback: (data: { status: string; releaseName?: string }) => void) => {
81
+ const listener = (_event: Electron.IpcRendererEvent, data: { status: string; releaseName?: string }) =>
82
+ callback(data);
83
+ ipcRenderer.on(IPC.UPDATE_AVAILABLE, listener);
84
+ return () => { ipcRenderer.removeListener(IPC.UPDATE_AVAILABLE, listener); };
85
+ },
86
+ onUpdateNotAvailable: (callback: (data: { status: string }) => void) => {
87
+ const listener = (_event: Electron.IpcRendererEvent, data: { status: string }) =>
88
+ callback(data);
89
+ ipcRenderer.on(IPC.UPDATE_NOT_AVAILABLE, listener);
90
+ return () => { ipcRenderer.removeListener(IPC.UPDATE_NOT_AVAILABLE, listener); };
91
+ },
92
+ onUpdateError: (callback: (data: { status: string; message: string }) => void) => {
93
+ const listener = (_event: Electron.IpcRendererEvent, data: { status: string; message: string }) =>
94
+ callback(data);
95
+ ipcRenderer.on(IPC.UPDATE_ERROR, listener);
96
+ return () => { ipcRenderer.removeListener(IPC.UPDATE_ERROR, listener); };
97
+ },
98
+ },
99
+ };
100
+
101
+ contextBridge.exposeInMainWorld('electronAPI', electronAPI);
102
+
103
+ // Expose clipboard via IPC for reliable copy/paste in terminal
104
+ const clipboardAPI = {
105
+ writeText: (text: string) => ipcRenderer.invoke(IPC.CLIPBOARD_WRITE, text),
106
+ readText: () => ipcRenderer.invoke(IPC.CLIPBOARD_READ) as Promise<string>,
107
+ };
108
+ contextBridge.exposeInMainWorld('clipboardAPI', clipboardAPI);
109
+
110
+ export type ElectronAPI = typeof electronAPI;
111
+ export type ClipboardAPI = typeof clipboardAPI;
@@ -0,0 +1,108 @@
1
+ import { contextBridge, ipcRenderer } from 'electron';
2
+ import { IPC } from '../shared/constants';
3
+
4
+ const electronAPI = {
5
+ pty: {
6
+ create: (options?: { shell?: string; cwd?: string; cols?: number; rows?: number }) =>
7
+ ipcRenderer.invoke(IPC.PTY_CREATE, options),
8
+ write: (id: string, data: string) =>
9
+ ipcRenderer.invoke(IPC.PTY_WRITE, id, data),
10
+ resize: (id: string, cols: number, rows: number) =>
11
+ ipcRenderer.invoke(IPC.PTY_RESIZE, id, cols, rows),
12
+ dispose: (id: string) =>
13
+ ipcRenderer.invoke(IPC.PTY_DISPOSE, id),
14
+ onData: (callback: (id: string, data: string) => void) => {
15
+ const listener = (_event: Electron.IpcRendererEvent, id: string, data: string) => callback(id, data);
16
+ ipcRenderer.on(IPC.PTY_DATA, listener);
17
+ return () => { ipcRenderer.removeListener(IPC.PTY_DATA, listener); };
18
+ },
19
+ onExit: (callback: (id: string, exitCode: number) => void) => {
20
+ const listener = (_event: Electron.IpcRendererEvent, id: string, exitCode: number) => callback(id, exitCode);
21
+ ipcRenderer.on(IPC.PTY_EXIT, listener);
22
+ return () => { ipcRenderer.removeListener(IPC.PTY_EXIT, listener); };
23
+ },
24
+ },
25
+ shell: {
26
+ list: () => ipcRenderer.invoke(IPC.SHELL_LIST) as Promise<{ name: string; path: string; args?: string[] }[]>,
27
+ },
28
+ session: {
29
+ save: (data: unknown) => ipcRenderer.invoke(IPC.SESSION_SAVE, data),
30
+ load: () => ipcRenderer.invoke(IPC.SESSION_LOAD),
31
+ },
32
+ settings: {
33
+ setToastEnabled: (enabled: boolean) => ipcRenderer.send(IPC.TOAST_ENABLED, enabled),
34
+ },
35
+ notification: {
36
+ onNew: (callback: (ptyId: string, data: { type: string; title: string; body: string }) => void) => {
37
+ const listener = (_event: Electron.IpcRendererEvent, ptyId: string, data: { type: string; title: string; body: string }) =>
38
+ callback(ptyId, data);
39
+ ipcRenderer.on(IPC.NOTIFICATION, listener);
40
+ return () => { ipcRenderer.removeListener(IPC.NOTIFICATION, listener); };
41
+ },
42
+ onCwdChanged: (callback: (ptyId: string, cwd: string) => void) => {
43
+ const listener = (_event: Electron.IpcRendererEvent, ptyId: string, cwd: string) =>
44
+ callback(ptyId, cwd);
45
+ ipcRenderer.on(IPC.CWD_CHANGED, listener);
46
+ return () => { ipcRenderer.removeListener(IPC.CWD_CHANGED, listener); };
47
+ },
48
+ },
49
+ metadata: {
50
+ request: (ptyId: string) =>
51
+ ipcRenderer.invoke(IPC.METADATA_REQUEST, ptyId),
52
+ onUpdate: (callback: (ptyId: string, data: { gitBranch?: string; cwd?: string; listeningPorts?: number[] }) => void) => {
53
+ const listener = (_event: Electron.IpcRendererEvent, ptyId: string, data: { gitBranch?: string; cwd?: string; listeningPorts?: number[] }) =>
54
+ callback(ptyId, data);
55
+ ipcRenderer.on(IPC.METADATA_UPDATE, listener);
56
+ return () => { ipcRenderer.removeListener(IPC.METADATA_UPDATE, listener); };
57
+ },
58
+ },
59
+ rpc: {
60
+ onCommand: (
61
+ callback: (requestId: string, method: string, params: Record<string, unknown>) => void,
62
+ ) => {
63
+ const listener = (
64
+ _event: Electron.IpcRendererEvent,
65
+ requestId: string,
66
+ method: string,
67
+ params: Record<string, unknown>,
68
+ ) => callback(requestId, method, params);
69
+ ipcRenderer.on(IPC.RPC_COMMAND, listener);
70
+ return () => { ipcRenderer.removeListener(IPC.RPC_COMMAND, listener); };
71
+ },
72
+ respond: (requestId: string, result: unknown) =>
73
+ ipcRenderer.send(`${IPC.RPC_RESPONSE}:${requestId}`, result),
74
+ },
75
+ updater: {
76
+ checkForUpdates: () =>
77
+ ipcRenderer.invoke(IPC.UPDATE_CHECK) as Promise<{ status: string }>,
78
+ installUpdate: () =>
79
+ ipcRenderer.invoke(IPC.UPDATE_INSTALL),
80
+ onUpdateAvailable: (callback: (data: { status: string; releaseName?: string }) => void) => {
81
+ const listener = (_event: Electron.IpcRendererEvent, data: { status: string; releaseName?: string }) =>
82
+ callback(data);
83
+ ipcRenderer.on(IPC.UPDATE_AVAILABLE, listener);
84
+ return () => { ipcRenderer.removeListener(IPC.UPDATE_AVAILABLE, listener); };
85
+ },
86
+ onUpdateNotAvailable: (callback: (data: { status: string }) => void) => {
87
+ const listener = (_event: Electron.IpcRendererEvent, data: { status: string }) =>
88
+ callback(data);
89
+ ipcRenderer.on(IPC.UPDATE_NOT_AVAILABLE, listener);
90
+ return () => { ipcRenderer.removeListener(IPC.UPDATE_NOT_AVAILABLE, listener); };
91
+ },
92
+ onUpdateError: (callback: (data: { status: string; message: string }) => void) => {
93
+ const listener = (_event: Electron.IpcRendererEvent, data: { status: string; message: string }) =>
94
+ callback(data);
95
+ ipcRenderer.on(IPC.UPDATE_ERROR, listener);
96
+ return () => { ipcRenderer.removeListener(IPC.UPDATE_ERROR, listener); };
97
+ },
98
+ },
99
+ };
100
+
101
+ contextBridge.exposeInMainWorld('electronAPI', electronAPI);
102
+
103
+ contextBridge.exposeInMainWorld('clipboardAPI', {
104
+ writeText: (text: string) => ipcRenderer.invoke(IPC.CLIPBOARD_WRITE, text),
105
+ readText: () => ipcRenderer.invoke(IPC.CLIPBOARD_READ) as Promise<string>,
106
+ });
107
+
108
+ export type ElectronAPI = typeof electronAPI;
@@ -0,0 +1,5 @@
1
+ import AppLayout from './components/Layout/AppLayout';
2
+
3
+ export default function App() {
4
+ return <AppLayout />;
5
+ }
@@ -0,0 +1,219 @@
1
+ import { useRef, useState, useEffect, useCallback } from 'react';
2
+ import BrowserToolbar from './BrowserToolbar';
3
+ import { useT } from '../../hooks/useT';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Declare the webview element for TypeScript
7
+ // ---------------------------------------------------------------------------
8
+
9
+ declare global {
10
+ // eslint-disable-next-line @typescript-eslint/no-namespace
11
+ namespace JSX {
12
+ interface IntrinsicElements {
13
+ webview: React.DetailedHTMLProps<
14
+ React.HTMLAttributes<Electron.WebviewTag> & {
15
+ src?: string;
16
+ partition?: string;
17
+ allowpopups?: string;
18
+ disablewebsecurity?: string;
19
+ preload?: string;
20
+ useragent?: string;
21
+ nodeintegration?: string;
22
+ webpreferences?: string;
23
+ },
24
+ Electron.WebviewTag
25
+ >;
26
+ }
27
+ }
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Props
32
+ // ---------------------------------------------------------------------------
33
+
34
+ interface BrowserPanelProps {
35
+ surfaceId: string;
36
+ initialUrl: string;
37
+ isActive: boolean;
38
+ onClose: () => void;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Component
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export default function BrowserPanel({ surfaceId, initialUrl, isActive, onClose }: BrowserPanelProps) {
46
+ const t = useT();
47
+ const webviewRef = useRef<Electron.WebviewTag>(null);
48
+ const [currentUrl, setCurrentUrl] = useState(initialUrl);
49
+ const [isLoading, setIsLoading] = useState(false);
50
+ const [canGoBack, setCanGoBack] = useState(false);
51
+ const [canGoForward, setCanGoForward] = useState(false);
52
+ const [pageTitle, setPageTitle] = useState(() => t('browser.title'));
53
+ const [isReady, setIsReady] = useState(false);
54
+
55
+ // Update nav state from webview
56
+ const updateNavState = useCallback(() => {
57
+ const wv = webviewRef.current;
58
+ if (!wv) return;
59
+ try {
60
+ setCanGoBack(wv.canGoBack());
61
+ setCanGoForward(wv.canGoForward());
62
+ } catch {
63
+ // Webview may not be ready yet
64
+ }
65
+ }, []);
66
+
67
+ // Attach webview event listeners once ready
68
+ useEffect(() => {
69
+ const wv = webviewRef.current;
70
+ if (!wv) return;
71
+
72
+ const onDomReady = () => {
73
+ setIsReady(true);
74
+ updateNavState();
75
+ };
76
+
77
+ const onStartLoading = () => {
78
+ setIsLoading(true);
79
+ };
80
+
81
+ const onStopLoading = () => {
82
+ setIsLoading(false);
83
+ updateNavState();
84
+ };
85
+
86
+ const onDidNavigate = (e: Electron.DidNavigateEvent) => {
87
+ setCurrentUrl(e.url);
88
+ updateNavState();
89
+ };
90
+
91
+ const onDidNavigateInPage = (e: Electron.DidNavigateInPageEvent) => {
92
+ setCurrentUrl(e.url);
93
+ updateNavState();
94
+ };
95
+
96
+ const onTitleUpdated = (e: Electron.PageTitleUpdatedEvent) => {
97
+ setPageTitle(e.title || t('browser.title'));
98
+ };
99
+
100
+ wv.addEventListener('dom-ready', onDomReady);
101
+ wv.addEventListener('did-start-loading', onStartLoading);
102
+ wv.addEventListener('did-stop-loading', onStopLoading);
103
+ wv.addEventListener('did-navigate', onDidNavigate as EventListener);
104
+ wv.addEventListener('did-navigate-in-page', onDidNavigateInPage as EventListener);
105
+ wv.addEventListener('page-title-updated', onTitleUpdated as EventListener);
106
+
107
+ return () => {
108
+ wv.removeEventListener('dom-ready', onDomReady);
109
+ wv.removeEventListener('did-start-loading', onStartLoading);
110
+ wv.removeEventListener('did-stop-loading', onStopLoading);
111
+ wv.removeEventListener('did-navigate', onDidNavigate as EventListener);
112
+ wv.removeEventListener('did-navigate-in-page', onDidNavigateInPage as EventListener);
113
+ wv.removeEventListener('page-title-updated', onTitleUpdated as EventListener);
114
+ };
115
+ }, [updateNavState]);
116
+
117
+ // F12 opens DevTools for the webview
118
+ useEffect(() => {
119
+ if (!isActive) return;
120
+ const handler = (e: KeyboardEvent) => {
121
+ if (e.key === 'F12') {
122
+ e.preventDefault();
123
+ handleOpenDevTools();
124
+ }
125
+ };
126
+ window.addEventListener('keydown', handler);
127
+ return () => window.removeEventListener('keydown', handler);
128
+ }, [isActive]);
129
+
130
+ const handleNavigate = useCallback((url: string) => {
131
+ const wv = webviewRef.current;
132
+ if (!wv) return;
133
+ if (isReady) {
134
+ wv.loadURL(url);
135
+ } else {
136
+ // If not ready yet, just update src attribute
137
+ wv.setAttribute('src', url);
138
+ }
139
+ setCurrentUrl(url);
140
+ }, [isReady]);
141
+
142
+ const handleBack = useCallback(() => {
143
+ webviewRef.current?.goBack();
144
+ }, []);
145
+
146
+ const handleForward = useCallback(() => {
147
+ webviewRef.current?.goForward();
148
+ }, []);
149
+
150
+ const handleRefresh = useCallback(() => {
151
+ webviewRef.current?.reload();
152
+ }, []);
153
+
154
+ const handleOpenDevTools = useCallback(() => {
155
+ try {
156
+ webviewRef.current?.openDevTools();
157
+ } catch {
158
+ // May not be available in all contexts
159
+ }
160
+ }, []);
161
+
162
+ return (
163
+ <div
164
+ className="flex flex-col h-full w-full overflow-hidden"
165
+ style={{
166
+ position: 'absolute',
167
+ inset: 0,
168
+ display: isActive ? 'flex' : 'none',
169
+ }}
170
+ >
171
+ {/* Title bar strip showing page title */}
172
+ <div
173
+ className="flex items-center gap-2 px-3 py-0.5 shrink-0"
174
+ style={{ backgroundColor: '#11111b', borderBottom: '1px solid var(--bg-base)' }}
175
+ >
176
+ {isLoading && (
177
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--accent-blue)] animate-pulse shrink-0" />
178
+ )}
179
+ <span
180
+ className="text-xs text-[var(--text-subtle)] truncate"
181
+ style={{ fontFamily: 'ui-monospace, monospace' }}
182
+ title={pageTitle}
183
+ >
184
+ {pageTitle}
185
+ </span>
186
+ </div>
187
+
188
+ {/* Toolbar */}
189
+ <BrowserToolbar
190
+ currentUrl={currentUrl}
191
+ isLoading={isLoading}
192
+ canGoBack={canGoBack}
193
+ canGoForward={canGoForward}
194
+ isActive={isActive}
195
+ onNavigate={handleNavigate}
196
+ onBack={handleBack}
197
+ onForward={handleForward}
198
+ onRefresh={handleRefresh}
199
+ onOpenDevTools={handleOpenDevTools}
200
+ onClose={onClose}
201
+ />
202
+
203
+ {/* WebView */}
204
+ <div className="flex-1 relative overflow-hidden" style={{ backgroundColor: 'var(--bg-base)' }}>
205
+ <webview
206
+ ref={webviewRef as React.RefObject<Electron.WebviewTag>}
207
+ src={initialUrl}
208
+ partition="persist:browser"
209
+ data-surface-id={surfaceId}
210
+ style={{
211
+ width: '100%',
212
+ height: '100%',
213
+ display: 'flex',
214
+ }}
215
+ />
216
+ </div>
217
+ </div>
218
+ );
219
+ }