@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.
- package/README.md +157 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.svg +6 -0
- package/dist/cli/cli/client.js +102 -0
- package/dist/cli/cli/commands/browser.js +137 -0
- package/dist/cli/cli/commands/input.js +80 -0
- package/dist/cli/cli/commands/notify.js +28 -0
- package/dist/cli/cli/commands/pane.js +88 -0
- package/dist/cli/cli/commands/surface.js +98 -0
- package/dist/cli/cli/commands/system.js +98 -0
- package/dist/cli/cli/commands/workspace.js +117 -0
- package/dist/cli/cli/index.js +140 -0
- package/dist/cli/cli/utils.js +47 -0
- package/dist/cli/shared/constants.js +54 -0
- package/dist/cli/shared/rpc.js +33 -0
- package/dist/cli/shared/types.js +79 -0
- package/dist/mcp/mcp/index.js +60 -0
- package/dist/mcp/mcp/wmux-client.js +146 -0
- package/dist/mcp/shared/constants.js +54 -0
- package/dist/mcp/shared/rpc.js +33 -0
- package/dist/mcp/shared/types.js +79 -0
- package/forge.config.ts +61 -0
- package/index.html +12 -0
- package/package.json +84 -0
- package/postcss.config.js +6 -0
- package/src/cli/client.ts +76 -0
- package/src/cli/commands/browser.ts +128 -0
- package/src/cli/commands/input.ts +72 -0
- package/src/cli/commands/notify.ts +29 -0
- package/src/cli/commands/pane.ts +90 -0
- package/src/cli/commands/surface.ts +102 -0
- package/src/cli/commands/system.ts +95 -0
- package/src/cli/commands/workspace.ts +116 -0
- package/src/cli/index.ts +145 -0
- package/src/cli/utils.ts +44 -0
- package/src/main/index.ts +86 -0
- package/src/main/ipc/handlers/clipboard.handler.ts +20 -0
- package/src/main/ipc/handlers/metadata.handler.ts +56 -0
- package/src/main/ipc/handlers/pty.handler.ts +69 -0
- package/src/main/ipc/handlers/session.handler.ts +17 -0
- package/src/main/ipc/handlers/shell.handler.ts +11 -0
- package/src/main/ipc/registerHandlers.ts +31 -0
- package/src/main/mcp/McpRegistrar.ts +156 -0
- package/src/main/metadata/MetadataCollector.ts +58 -0
- package/src/main/notification/ToastManager.ts +32 -0
- package/src/main/pipe/PipeServer.ts +190 -0
- package/src/main/pipe/RpcRouter.ts +46 -0
- package/src/main/pipe/handlers/_bridge.ts +40 -0
- package/src/main/pipe/handlers/browser.rpc.ts +132 -0
- package/src/main/pipe/handlers/input.rpc.ts +120 -0
- package/src/main/pipe/handlers/meta.rpc.ts +59 -0
- package/src/main/pipe/handlers/notify.rpc.ts +53 -0
- package/src/main/pipe/handlers/pane.rpc.ts +39 -0
- package/src/main/pipe/handlers/surface.rpc.ts +43 -0
- package/src/main/pipe/handlers/system.rpc.ts +36 -0
- package/src/main/pipe/handlers/workspace.rpc.ts +52 -0
- package/src/main/pty/AgentDetector.ts +247 -0
- package/src/main/pty/OscParser.ts +81 -0
- package/src/main/pty/PTYBridge.ts +88 -0
- package/src/main/pty/PTYManager.ts +104 -0
- package/src/main/pty/ShellDetector.ts +63 -0
- package/src/main/session/SessionManager.ts +53 -0
- package/src/main/updater/AutoUpdater.ts +132 -0
- package/src/main/window/createWindow.ts +71 -0
- package/src/mcp/README.md +56 -0
- package/src/mcp/index.ts +153 -0
- package/src/mcp/wmux-client.ts +127 -0
- package/src/preload/index.ts +111 -0
- package/src/preload/preload.ts +108 -0
- package/src/renderer/App.tsx +5 -0
- package/src/renderer/components/Browser/BrowserPanel.tsx +219 -0
- package/src/renderer/components/Browser/BrowserToolbar.tsx +253 -0
- package/src/renderer/components/Company/ApprovalDialog.tsx +3 -0
- package/src/renderer/components/Company/CompanyView.tsx +7 -0
- package/src/renderer/components/Company/MessageFeedPanel.tsx +3 -0
- package/src/renderer/components/Layout/AppLayout.tsx +234 -0
- package/src/renderer/components/Notification/NotificationPanel.tsx +129 -0
- package/src/renderer/components/Palette/CommandPalette.tsx +409 -0
- package/src/renderer/components/Palette/PaletteItem.tsx +55 -0
- package/src/renderer/components/Pane/Pane.tsx +122 -0
- package/src/renderer/components/Pane/PaneContainer.tsx +41 -0
- package/src/renderer/components/Pane/SurfaceTabs.tsx +46 -0
- package/src/renderer/components/Settings/SettingsPanel.tsx +886 -0
- package/src/renderer/components/Sidebar/MiniSidebar.tsx +67 -0
- package/src/renderer/components/Sidebar/Sidebar.tsx +84 -0
- package/src/renderer/components/Sidebar/WorkspaceItem.tsx +241 -0
- package/src/renderer/components/StatusBar/StatusBar.tsx +93 -0
- package/src/renderer/components/Terminal/SearchBar.tsx +126 -0
- package/src/renderer/components/Terminal/Terminal.tsx +102 -0
- package/src/renderer/components/Terminal/ViCopyMode.tsx +104 -0
- package/src/renderer/hooks/useKeyboard.ts +310 -0
- package/src/renderer/hooks/useNotificationListener.ts +80 -0
- package/src/renderer/hooks/useNotificationSound.ts +75 -0
- package/src/renderer/hooks/useRpcBridge.ts +451 -0
- package/src/renderer/hooks/useT.ts +11 -0
- package/src/renderer/hooks/useTerminal.ts +349 -0
- package/src/renderer/hooks/useViCopyMode.ts +320 -0
- package/src/renderer/i18n/index.ts +69 -0
- package/src/renderer/i18n/locales/en.ts +157 -0
- package/src/renderer/i18n/locales/ja.ts +155 -0
- package/src/renderer/i18n/locales/ko.ts +155 -0
- package/src/renderer/i18n/locales/zh.ts +155 -0
- package/src/renderer/index.tsx +6 -0
- package/src/renderer/stores/index.ts +19 -0
- package/src/renderer/stores/slices/notificationSlice.ts +56 -0
- package/src/renderer/stores/slices/paneSlice.ts +141 -0
- package/src/renderer/stores/slices/surfaceSlice.ts +122 -0
- package/src/renderer/stores/slices/uiSlice.ts +247 -0
- package/src/renderer/stores/slices/workspaceSlice.ts +120 -0
- package/src/renderer/styles/globals.css +150 -0
- package/src/renderer/themes.ts +99 -0
- package/src/shared/constants.ts +53 -0
- package/src/shared/electron.d.ts +11 -0
- package/src/shared/rpc.ts +71 -0
- package/src/shared/types.ts +176 -0
- package/tailwind.config.js +11 -0
- package/tsconfig.cli.json +24 -0
- package/tsconfig.json +21 -0
- package/tsconfig.mcp.json +25 -0
- package/vite.main.config.ts +14 -0
- package/vite.preload.config.ts +9 -0
- package/vite.renderer.config.ts +6 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export class MetadataCollector {
|
|
7
|
+
async getGitBranch(cwd: string): Promise<string | undefined> {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
10
|
+
cwd,
|
|
11
|
+
timeout: 3000,
|
|
12
|
+
});
|
|
13
|
+
const branch = stdout.trim();
|
|
14
|
+
return branch || undefined;
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async getListeningPorts(pid?: number): Promise<number[]> {
|
|
21
|
+
if (pid !== undefined && !(Number.isInteger(pid) && pid > 0)) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
// Use PowerShell to get listening TCP connections
|
|
26
|
+
const script = pid
|
|
27
|
+
? `Get-NetTCPConnection -State Listen -OwningProcess ${pid} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty LocalPort`
|
|
28
|
+
: `Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.OwningProcess -ne 0 -and $_.OwningProcess -ne 4 } | Select-Object -ExpandProperty LocalPort | Sort-Object -Unique | Select-Object -First 20`;
|
|
29
|
+
|
|
30
|
+
const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-Command', script], {
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const ports = stdout
|
|
35
|
+
.trim()
|
|
36
|
+
.split(/\r?\n/)
|
|
37
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
38
|
+
.filter((p) => !isNaN(p) && p > 0);
|
|
39
|
+
|
|
40
|
+
return [...new Set(ports)].sort((a, b) => a - b);
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async collect(cwd?: string): Promise<{ gitBranch?: string; cwd?: string; listeningPorts?: number[] }> {
|
|
47
|
+
const [gitBranch, listeningPorts] = await Promise.all([
|
|
48
|
+
cwd ? this.getGitBranch(cwd) : Promise.resolve(undefined),
|
|
49
|
+
this.getListeningPorts(),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
gitBranch,
|
|
54
|
+
cwd,
|
|
55
|
+
listeningPorts: listeningPorts.length > 0 ? listeningPorts : undefined,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Notification, BrowserWindow } from 'electron';
|
|
2
|
+
|
|
3
|
+
export class ToastManager {
|
|
4
|
+
enabled = true;
|
|
5
|
+
|
|
6
|
+
show(title: string, body: string): void {
|
|
7
|
+
if (!this.enabled) return;
|
|
8
|
+
|
|
9
|
+
// Only show toast when app is not focused
|
|
10
|
+
const focusedWindow = BrowserWindow.getFocusedWindow();
|
|
11
|
+
if (focusedWindow) return;
|
|
12
|
+
|
|
13
|
+
if (!Notification.isSupported()) return;
|
|
14
|
+
|
|
15
|
+
const notification = new Notification({
|
|
16
|
+
title,
|
|
17
|
+
body,
|
|
18
|
+
silent: false,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
notification.on('click', () => {
|
|
22
|
+
// Bring app to front when toast is clicked
|
|
23
|
+
const win = BrowserWindow.getAllWindows()[0];
|
|
24
|
+
if (win) {
|
|
25
|
+
if (win.isMinimized()) win.restore();
|
|
26
|
+
win.focus();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
notification.show();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import * as net from 'net';
|
|
2
|
+
import * as crypto from 'crypto';
|
|
3
|
+
import { getPipeName } from '../../shared/constants';
|
|
4
|
+
import type { RpcRequest } from '../../shared/rpc';
|
|
5
|
+
import { RpcRouter } from './RpcRouter';
|
|
6
|
+
|
|
7
|
+
const MAX_LINE_BUFFER = 1024 * 1024; // 1 MB — prevent OOM from malicious clients
|
|
8
|
+
|
|
9
|
+
export class PipeServer {
|
|
10
|
+
private server: net.Server | null = null;
|
|
11
|
+
private readonly router: RpcRouter;
|
|
12
|
+
private readonly connectedSockets = new Set<net.Socket>();
|
|
13
|
+
private readonly authToken: string;
|
|
14
|
+
private readonly rateLimits = new Map<net.Socket, { count: number; resetAt: number }>();
|
|
15
|
+
|
|
16
|
+
constructor(router: RpcRouter) {
|
|
17
|
+
this.router = router;
|
|
18
|
+
this.authToken = crypto.randomUUID();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getAuthToken(): string {
|
|
22
|
+
return this.authToken;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
start(): void {
|
|
26
|
+
if (this.server) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.server = net.createServer((socket) => {
|
|
31
|
+
this.connectedSockets.add(socket);
|
|
32
|
+
socket.on('close', () => {
|
|
33
|
+
this.connectedSockets.delete(socket);
|
|
34
|
+
this.rateLimits.delete(socket);
|
|
35
|
+
});
|
|
36
|
+
this.handleConnection(socket);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
|
40
|
+
if (err.code === 'EADDRINUSE') {
|
|
41
|
+
console.warn('[PipeServer] EADDRINUSE — retrying in 1s...');
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
if (this.server) {
|
|
44
|
+
this.server.close();
|
|
45
|
+
this.server.listen(getPipeName());
|
|
46
|
+
}
|
|
47
|
+
}, 1000);
|
|
48
|
+
} else {
|
|
49
|
+
console.error('[PipeServer] Server error:', err);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const pipeName = getPipeName();
|
|
54
|
+
this.server.listen(pipeName, () => {
|
|
55
|
+
console.log(`[PipeServer] Listening on ${pipeName}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
stop(): void {
|
|
60
|
+
if (!this.server) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Destroy all connected sockets
|
|
65
|
+
for (const socket of this.connectedSockets) {
|
|
66
|
+
socket.destroy();
|
|
67
|
+
}
|
|
68
|
+
this.connectedSockets.clear();
|
|
69
|
+
|
|
70
|
+
this.server.close((err) => {
|
|
71
|
+
if (err) {
|
|
72
|
+
console.error('[PipeServer] Error closing server:', err);
|
|
73
|
+
} else {
|
|
74
|
+
console.log('[PipeServer] Server closed.');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this.server = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private handleConnection(socket: net.Socket): void {
|
|
82
|
+
console.log('[PipeServer] Client connected.');
|
|
83
|
+
|
|
84
|
+
let buffer = '';
|
|
85
|
+
|
|
86
|
+
socket.setEncoding('utf8');
|
|
87
|
+
|
|
88
|
+
socket.on('data', (chunk: string) => {
|
|
89
|
+
buffer += chunk;
|
|
90
|
+
|
|
91
|
+
// Security: prevent OOM from clients that never send newlines
|
|
92
|
+
if (buffer.length > MAX_LINE_BUFFER) {
|
|
93
|
+
console.warn('[PipeServer] Client exceeded max buffer size — disconnecting.');
|
|
94
|
+
socket.destroy();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lines = buffer.split('\n');
|
|
99
|
+
// 마지막 요소는 아직 완성되지 않은 부분 — 다음 청크를 기다림
|
|
100
|
+
buffer = lines.pop() ?? '';
|
|
101
|
+
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
const trimmed = line.trim();
|
|
104
|
+
if (!trimmed) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
this.processLine(socket, trimmed);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
socket.on('end', () => {
|
|
112
|
+
// 연결 종료 시 남은 버퍼 처리
|
|
113
|
+
const trimmed = buffer.trim();
|
|
114
|
+
if (trimmed) {
|
|
115
|
+
this.processLine(socket, trimmed);
|
|
116
|
+
}
|
|
117
|
+
buffer = '';
|
|
118
|
+
console.log('[PipeServer] Client disconnected.');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
socket.on('error', (err) => {
|
|
122
|
+
console.error('[PipeServer] Socket error:', err);
|
|
123
|
+
socket.destroy();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private processLine(socket: net.Socket, line: string): void {
|
|
128
|
+
let request: RpcRequest;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
request = JSON.parse(line) as RpcRequest;
|
|
132
|
+
} catch {
|
|
133
|
+
const errorResponse = JSON.stringify({
|
|
134
|
+
id: null,
|
|
135
|
+
ok: false,
|
|
136
|
+
error: 'Invalid JSON',
|
|
137
|
+
});
|
|
138
|
+
socket.write(errorResponse + '\n');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Rate limiting: max 50 requests per second per socket
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
let limit = this.rateLimits.get(socket);
|
|
145
|
+
if (!limit || now > limit.resetAt) {
|
|
146
|
+
limit = { count: 0, resetAt: now + 1000 };
|
|
147
|
+
this.rateLimits.set(socket, limit);
|
|
148
|
+
}
|
|
149
|
+
limit.count++;
|
|
150
|
+
if (limit.count > 50) {
|
|
151
|
+
const rateLimitResponse = JSON.stringify({
|
|
152
|
+
id: request.id,
|
|
153
|
+
ok: false,
|
|
154
|
+
error: 'rate limited',
|
|
155
|
+
});
|
|
156
|
+
socket.write(rateLimitResponse + '\n');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Authenticate: every request must carry a valid token
|
|
161
|
+
if (request.token !== this.authToken) {
|
|
162
|
+
const unauthorizedResponse = JSON.stringify({
|
|
163
|
+
id: request.id,
|
|
164
|
+
ok: false,
|
|
165
|
+
error: 'unauthorized',
|
|
166
|
+
});
|
|
167
|
+
socket.write(unauthorizedResponse + '\n');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.router
|
|
172
|
+
.dispatch(request)
|
|
173
|
+
.then((response) => {
|
|
174
|
+
if (!socket.destroyed) {
|
|
175
|
+
socket.write(JSON.stringify(response) + '\n');
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
.catch((err: unknown) => {
|
|
179
|
+
console.error('[PipeServer] Dispatch error:', err);
|
|
180
|
+
if (!socket.destroyed) {
|
|
181
|
+
const errorResponse = JSON.stringify({
|
|
182
|
+
id: request.id,
|
|
183
|
+
ok: false,
|
|
184
|
+
error: 'Internal server error',
|
|
185
|
+
});
|
|
186
|
+
socket.write(errorResponse + '\n');
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { RpcMethod, RpcRequest, RpcResponse } from '../../shared/rpc';
|
|
2
|
+
|
|
3
|
+
type RpcHandler = (params: Record<string, unknown>) => Promise<unknown>;
|
|
4
|
+
|
|
5
|
+
export class RpcRouter {
|
|
6
|
+
private readonly handlers = new Map<RpcMethod, RpcHandler>();
|
|
7
|
+
|
|
8
|
+
register(method: RpcMethod, handler: RpcHandler): void {
|
|
9
|
+
this.handlers.set(method, handler);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async dispatch(request: RpcRequest): Promise<RpcResponse> {
|
|
13
|
+
if (!request || typeof request.id !== 'string' || typeof request.method !== 'string') {
|
|
14
|
+
return { id: (request as RpcRequest)?.id || '', ok: false, error: 'Invalid RPC request: missing id or method' };
|
|
15
|
+
}
|
|
16
|
+
if (request.params !== undefined && (typeof request.params !== 'object' || request.params === null)) {
|
|
17
|
+
return { id: request.id, ok: false, error: 'Invalid RPC request: params must be an object' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const handler = this.handlers.get(request.method);
|
|
21
|
+
|
|
22
|
+
if (!handler) {
|
|
23
|
+
return {
|
|
24
|
+
id: request.id,
|
|
25
|
+
ok: false,
|
|
26
|
+
error: `Unknown method: ${request.method}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const result = await handler(request.params);
|
|
32
|
+
return {
|
|
33
|
+
id: request.id,
|
|
34
|
+
ok: true,
|
|
35
|
+
result,
|
|
36
|
+
};
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
return {
|
|
40
|
+
id: request.id,
|
|
41
|
+
ok: false,
|
|
42
|
+
error: message,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ipcMain, type BrowserWindow } from 'electron';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { IPC } from '../../../shared/constants';
|
|
4
|
+
|
|
5
|
+
type GetWindow = () => BrowserWindow | null;
|
|
6
|
+
|
|
7
|
+
const TIMEOUT_MS = 5000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sends a RPC command to the renderer via IPC and waits for the response.
|
|
11
|
+
* Uses a unique requestId per call so concurrent requests don't collide.
|
|
12
|
+
*/
|
|
13
|
+
export function sendToRenderer(
|
|
14
|
+
getWindow: GetWindow,
|
|
15
|
+
method: string,
|
|
16
|
+
params: Record<string, unknown> = {},
|
|
17
|
+
): Promise<unknown> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const win = getWindow();
|
|
20
|
+
if (!win || win.isDestroyed()) {
|
|
21
|
+
reject(new Error('BrowserWindow is not available'));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const requestId = `rpc-${randomUUID()}`;
|
|
26
|
+
const responseChannel = `${IPC.RPC_RESPONSE}:${requestId}`;
|
|
27
|
+
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
ipcMain.removeAllListeners(responseChannel);
|
|
30
|
+
reject(new Error(`RPC timeout: ${method} (${TIMEOUT_MS}ms)`));
|
|
31
|
+
}, TIMEOUT_MS);
|
|
32
|
+
|
|
33
|
+
ipcMain.once(responseChannel, (_event, result: unknown) => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
resolve(result);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
win.webContents.send(IPC.RPC_COMMAND, requestId, method, params);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { BrowserWindow } from 'electron';
|
|
2
|
+
import type { RpcRouter } from '../RpcRouter';
|
|
3
|
+
import { sendToRenderer } from './_bridge';
|
|
4
|
+
|
|
5
|
+
type GetWindow = () => BrowserWindow | null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registers browser.* RPC handlers.
|
|
9
|
+
*
|
|
10
|
+
* All commands are delegated to the renderer process via IPC where the active
|
|
11
|
+
* browser Surface's <webview> element executes the requested operation.
|
|
12
|
+
*/
|
|
13
|
+
export function registerBrowserRpc(router: RpcRouter, getWindow: GetWindow): void {
|
|
14
|
+
/**
|
|
15
|
+
* browser.open
|
|
16
|
+
* Opens a new browser surface in the active pane.
|
|
17
|
+
* params: { url?: string }
|
|
18
|
+
*/
|
|
19
|
+
router.register('browser.open', (params) => {
|
|
20
|
+
const url = typeof params['url'] === 'string' ? params['url'] : undefined;
|
|
21
|
+
return sendToRenderer(getWindow, 'browser.open', {
|
|
22
|
+
...(url && { url }),
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* browser.snapshot
|
|
28
|
+
* Returns the full outer HTML of the current page as a string.
|
|
29
|
+
* params: {}
|
|
30
|
+
*/
|
|
31
|
+
router.register('browser.snapshot', (params) => {
|
|
32
|
+
const surfaceId = typeof params['surfaceId'] === 'string' ? params['surfaceId'] : undefined;
|
|
33
|
+
return sendToRenderer(getWindow, 'browser.snapshot', {
|
|
34
|
+
...(surfaceId && { surfaceId }),
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* browser.click
|
|
40
|
+
* Clicks the first element matching the given CSS selector.
|
|
41
|
+
* params: { selector: string }
|
|
42
|
+
*/
|
|
43
|
+
router.register('browser.click', (params) => {
|
|
44
|
+
if (typeof params['selector'] !== 'string' || params['selector'].length === 0) {
|
|
45
|
+
throw new Error('browser.click: missing required param "selector"');
|
|
46
|
+
}
|
|
47
|
+
const surfaceId = typeof params['surfaceId'] === 'string' ? params['surfaceId'] : undefined;
|
|
48
|
+
return sendToRenderer(getWindow, 'browser.click', {
|
|
49
|
+
selector: params['selector'],
|
|
50
|
+
...(surfaceId && { surfaceId }),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* browser.fill
|
|
56
|
+
* Sets the value of an input element matching the given CSS selector.
|
|
57
|
+
* params: { selector: string; text: string }
|
|
58
|
+
*/
|
|
59
|
+
router.register('browser.fill', (params) => {
|
|
60
|
+
if (typeof params['selector'] !== 'string' || params['selector'].length === 0) {
|
|
61
|
+
throw new Error('browser.fill: missing required param "selector"');
|
|
62
|
+
}
|
|
63
|
+
if (typeof params['text'] !== 'string') {
|
|
64
|
+
throw new Error('browser.fill: missing required param "text"');
|
|
65
|
+
}
|
|
66
|
+
const surfaceId = typeof params['surfaceId'] === 'string' ? params['surfaceId'] : undefined;
|
|
67
|
+
return sendToRenderer(getWindow, 'browser.fill', {
|
|
68
|
+
selector: params['selector'],
|
|
69
|
+
text: params['text'],
|
|
70
|
+
...(surfaceId && { surfaceId }),
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* browser.eval
|
|
76
|
+
* Evaluates arbitrary JavaScript in the context of the current page.
|
|
77
|
+
* params: { code: string }
|
|
78
|
+
*/
|
|
79
|
+
router.register('browser.eval', (params) => {
|
|
80
|
+
if (typeof params['code'] !== 'string' || params['code'].length === 0) {
|
|
81
|
+
throw new Error('browser.eval: missing required param "code"');
|
|
82
|
+
}
|
|
83
|
+
// Security: block patterns that could escape webview sandbox
|
|
84
|
+
const code = params['code'];
|
|
85
|
+
const dangerousPatterns = [
|
|
86
|
+
/\brequire\s*\(/i,
|
|
87
|
+
/\bprocess\s*\./i,
|
|
88
|
+
/\b__dirname\b/i,
|
|
89
|
+
/\b__filename\b/i,
|
|
90
|
+
/\bchild_process\b/i,
|
|
91
|
+
/\bglobal\s*\.\s*process\b/i,
|
|
92
|
+
/\belectron\b/i,
|
|
93
|
+
];
|
|
94
|
+
for (const pat of dangerousPatterns) {
|
|
95
|
+
if (pat.test(code)) {
|
|
96
|
+
throw new Error('browser.eval: code contains blocked pattern');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const surfaceId = typeof params['surfaceId'] === 'string' ? params['surfaceId'] : undefined;
|
|
100
|
+
return sendToRenderer(getWindow, 'browser.eval', {
|
|
101
|
+
code,
|
|
102
|
+
...(surfaceId && { surfaceId }),
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* browser.navigate
|
|
108
|
+
* Navigates the active browser Surface to the given URL.
|
|
109
|
+
* params: { url: string }
|
|
110
|
+
*/
|
|
111
|
+
router.register('browser.navigate', (params) => {
|
|
112
|
+
if (typeof params['url'] !== 'string' || params['url'].length === 0) {
|
|
113
|
+
throw new Error('browser.navigate: missing required param "url"');
|
|
114
|
+
}
|
|
115
|
+
// Security: block dangerous URL schemes
|
|
116
|
+
const url = params['url'];
|
|
117
|
+
const normalizedUrl = url.trim().toLowerCase();
|
|
118
|
+
if (
|
|
119
|
+
normalizedUrl.startsWith('javascript:') ||
|
|
120
|
+
normalizedUrl.startsWith('data:') ||
|
|
121
|
+
normalizedUrl.startsWith('vbscript:') ||
|
|
122
|
+
normalizedUrl.startsWith('file:')
|
|
123
|
+
) {
|
|
124
|
+
throw new Error(`browser.navigate: blocked URL scheme`);
|
|
125
|
+
}
|
|
126
|
+
const surfaceId = typeof params['surfaceId'] === 'string' ? params['surfaceId'] : undefined;
|
|
127
|
+
return sendToRenderer(getWindow, 'browser.navigate', {
|
|
128
|
+
url,
|
|
129
|
+
...(surfaceId && { surfaceId }),
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { BrowserWindow } from 'electron';
|
|
2
|
+
import type { RpcRouter } from '../RpcRouter';
|
|
3
|
+
import type { PTYManager } from '../../pty/PTYManager';
|
|
4
|
+
import { sendToRenderer } from './_bridge';
|
|
5
|
+
|
|
6
|
+
type GetWindow = () => BrowserWindow | null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Key sequence mapping table for input.sendKey
|
|
10
|
+
*/
|
|
11
|
+
const KEY_MAP: Readonly<Record<string, string>> = {
|
|
12
|
+
enter: '\r',
|
|
13
|
+
tab: '\t',
|
|
14
|
+
'ctrl+c': '\x03',
|
|
15
|
+
'ctrl+d': '\x04',
|
|
16
|
+
'ctrl+z': '\x1a',
|
|
17
|
+
'ctrl+l': '\x0c',
|
|
18
|
+
escape: '\x1b',
|
|
19
|
+
up: '\x1b[A',
|
|
20
|
+
down: '\x1b[B',
|
|
21
|
+
right: '\x1b[C',
|
|
22
|
+
left: '\x1b[D',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the active ptyId from the renderer when none is provided.
|
|
27
|
+
* Asks the renderer for the currently focused surface's ptyId.
|
|
28
|
+
*/
|
|
29
|
+
async function resolveActivePtyId(getWindow: GetWindow): Promise<string> {
|
|
30
|
+
const result = await sendToRenderer(getWindow, 'input.readScreen');
|
|
31
|
+
// renderer returns { ptyId: string, ... } for the active surface
|
|
32
|
+
if (
|
|
33
|
+
result !== null &&
|
|
34
|
+
typeof result === 'object' &&
|
|
35
|
+
'ptyId' in result &&
|
|
36
|
+
typeof (result as Record<string, unknown>)['ptyId'] === 'string'
|
|
37
|
+
) {
|
|
38
|
+
return (result as Record<string, string>)['ptyId'];
|
|
39
|
+
}
|
|
40
|
+
throw new Error('input: could not resolve active ptyId from renderer');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerInputRpc(
|
|
44
|
+
router: RpcRouter,
|
|
45
|
+
ptyManager: PTYManager,
|
|
46
|
+
getWindow: GetWindow,
|
|
47
|
+
): void {
|
|
48
|
+
/**
|
|
49
|
+
* input.send — writes text to a PTY session.
|
|
50
|
+
* params: { text: string, ptyId?: string }
|
|
51
|
+
* If ptyId is omitted the renderer is queried for the active surface's ptyId.
|
|
52
|
+
*/
|
|
53
|
+
router.register('input.send', async (params) => {
|
|
54
|
+
if (typeof params['text'] !== 'string') {
|
|
55
|
+
throw new Error('input.send: missing required param "text"');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const text = params['text'];
|
|
59
|
+
let ptyId: string;
|
|
60
|
+
|
|
61
|
+
if (typeof params['ptyId'] === 'string' && params['ptyId'].length > 0) {
|
|
62
|
+
ptyId = params['ptyId'];
|
|
63
|
+
} else {
|
|
64
|
+
ptyId = await resolveActivePtyId(getWindow);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const instance = ptyManager.get(ptyId);
|
|
68
|
+
if (!instance) {
|
|
69
|
+
throw new Error(`input.send: PTY not found — id="${ptyId}"`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ptyManager.write(ptyId, text);
|
|
73
|
+
return { ok: true, ptyId };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* input.sendKey — maps a named key to an ANSI sequence and writes it.
|
|
78
|
+
* params: { key: string, ptyId?: string }
|
|
79
|
+
* Supported keys: enter, tab, ctrl+c, ctrl+d, ctrl+z, ctrl+l,
|
|
80
|
+
* escape, up, down, right, left
|
|
81
|
+
*/
|
|
82
|
+
router.register('input.sendKey', async (params) => {
|
|
83
|
+
if (typeof params['key'] !== 'string') {
|
|
84
|
+
throw new Error('input.sendKey: missing required param "key"');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const key = params['key'].toLowerCase();
|
|
88
|
+
const sequence = KEY_MAP[key];
|
|
89
|
+
if (sequence === undefined) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`input.sendKey: unknown key "${params['key']}". ` +
|
|
92
|
+
`Supported: ${Object.keys(KEY_MAP).join(', ')}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let ptyId: string;
|
|
97
|
+
if (typeof params['ptyId'] === 'string' && params['ptyId'].length > 0) {
|
|
98
|
+
ptyId = params['ptyId'];
|
|
99
|
+
} else {
|
|
100
|
+
ptyId = await resolveActivePtyId(getWindow);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const instance = ptyManager.get(ptyId);
|
|
104
|
+
if (!instance) {
|
|
105
|
+
throw new Error(`input.sendKey: PTY not found — id="${ptyId}"`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
ptyManager.write(ptyId, sequence);
|
|
109
|
+
return { ok: true, ptyId, key, sequence };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* input.readScreen — delegates to the renderer to capture the current
|
|
114
|
+
* terminal viewport text of the active surface.
|
|
115
|
+
* Returns { ptyId: string, text: string }
|
|
116
|
+
*/
|
|
117
|
+
router.register('input.readScreen', (_params) =>
|
|
118
|
+
sendToRenderer(getWindow, 'input.readScreen'),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { BrowserWindow } from 'electron';
|
|
2
|
+
import type { RpcRouter } from '../RpcRouter';
|
|
3
|
+
import { IPC } from '../../../shared/constants';
|
|
4
|
+
|
|
5
|
+
type GetWindow = () => BrowserWindow | null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sub-channel names embedded in the METADATA_UPDATE IPC message.
|
|
9
|
+
* The renderer's useNotificationListener (or a dedicated metadata listener)
|
|
10
|
+
* discriminates on the `kind` field.
|
|
11
|
+
*/
|
|
12
|
+
type MetaUpdateKind = 'status' | 'progress';
|
|
13
|
+
|
|
14
|
+
interface MetaStatusPayload {
|
|
15
|
+
kind: 'status';
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MetaProgressPayload {
|
|
20
|
+
kind: 'progress';
|
|
21
|
+
value: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type MetaPayload = MetaStatusPayload | MetaProgressPayload;
|
|
25
|
+
|
|
26
|
+
function sendMeta(getWindow: GetWindow, payload: MetaPayload): Promise<{ ok: boolean }> {
|
|
27
|
+
const win = getWindow();
|
|
28
|
+
if (!win || win.isDestroyed()) {
|
|
29
|
+
return Promise.reject(new Error('meta: BrowserWindow is not available'));
|
|
30
|
+
}
|
|
31
|
+
win.webContents.send(IPC.METADATA_UPDATE, payload);
|
|
32
|
+
return Promise.resolve({ ok: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerMetaRpc(router: RpcRouter, getWindow: GetWindow): void {
|
|
36
|
+
/**
|
|
37
|
+
* meta.setStatus — sets an arbitrary status text string in the renderer.
|
|
38
|
+
* params: { text: string }
|
|
39
|
+
*/
|
|
40
|
+
router.register('meta.setStatus', (params) => {
|
|
41
|
+
if (typeof params['text'] !== 'string') {
|
|
42
|
+
throw new Error('meta.setStatus: missing required param "text"');
|
|
43
|
+
}
|
|
44
|
+
return sendMeta(getWindow, { kind: 'status', text: params['text'] });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* meta.setProgress — sets a progress value (0–100) in the renderer.
|
|
49
|
+
* params: { value: number }
|
|
50
|
+
* Values outside 0–100 are clamped.
|
|
51
|
+
*/
|
|
52
|
+
router.register('meta.setProgress', (params) => {
|
|
53
|
+
if (typeof params['value'] !== 'number') {
|
|
54
|
+
throw new Error('meta.setProgress: missing required param "value" (number)');
|
|
55
|
+
}
|
|
56
|
+
const value = Math.min(100, Math.max(0, params['value']));
|
|
57
|
+
return sendMeta(getWindow, { kind: 'progress', value });
|
|
58
|
+
});
|
|
59
|
+
}
|