claude-code-session-manager 0.62.0 → 0.63.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/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
- package/dist/assets/History-mWbqemhZ.js +2 -0
- package/dist/assets/Hooks-D_8sciZu.js +3 -0
- package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
- package/dist/assets/Library-CG1LDXLw.js +46 -0
- package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
- package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
- package/dist/assets/McpServers-Kc-djiVi.js +2 -0
- package/dist/assets/Memory-B60DGRTW.js +8 -0
- package/dist/assets/Panel-CbTPsYHq.js +1 -0
- package/dist/assets/Permissions-C8KrSfrd.js +3 -0
- package/dist/assets/Plugins-BT8EumHb.js +2 -0
- package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
- package/dist/assets/Scheduler-DbKPd0or.js +14 -0
- package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
- package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
- package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
- package/dist/assets/Skills-CVL_4X3T.js +3 -0
- package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
- package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
- package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
- package/dist/assets/Toggle-C0a-6xV7.js +1 -0
- package/dist/assets/index-CO_7DroC.js +3066 -0
- package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
- package/dist/assets/listSkills-QORduPIk.js +1 -0
- package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
- package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
- package/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
- package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
- package/src/main/__tests__/runLogRetention.test.cjs +343 -0
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
- package/src/main/config.cjs +32 -29
- package/src/main/files.cjs +20 -0
- package/src/main/health.cjs +32 -0
- package/src/main/heapSnapshot.cjs +122 -0
- package/src/main/historyAggregator.cjs +176 -36
- package/src/main/index.cjs +10 -63
- package/src/main/ipcSchemas.cjs +20 -209
- package/src/main/lib/opsOwnership.cjs +0 -2
- package/src/main/lib/runLogRetention.cjs +358 -0
- package/src/main/lib/summarize.cjs +0 -1
- package/src/main/runVerify.cjs +55 -6
- package/src/main/scheduler.cjs +46 -7
- package/src/main/transcripts.cjs +29 -5
- package/src/preload/api.d.ts +18 -199
- package/src/preload/index.cjs +12 -102
- package/dist/assets/index-BfiMRkln.js +0 -3225
- package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
- package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
- package/src/main/browserAgentServer.cjs +0 -224
- package/src/main/browserCapture.cjs +0 -728
- package/src/main/browserView.cjs +0 -916
- package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
- package/src/main/lib/browserAgentActions.cjs +0 -114
- package/src/main/lib/e2eStateMachine.cjs +0 -39
- package/src/main/webRemote.cjs +0 -1367
- package/src/preload/browserViewPreload.cjs +0 -118
package/src/main/browserView.cjs
DELETED
|
@@ -1,916 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Owns the native WebContentsView(s) that embed real web content inside the
|
|
3
|
-
* Browser tab. `<webview>` and iframes are blocked app-wide (see index.cjs
|
|
4
|
-
* will-attach-webview + CSP frame-src 'none'), so this is the only way to
|
|
5
|
-
* show remote content: a WebContentsView is a sibling layer the main process
|
|
6
|
-
* positions directly on top of the renderer's BrowserWindow, keyed by a
|
|
7
|
-
* renderer-generated viewId.
|
|
8
|
-
*
|
|
9
|
-
* PRD 399 created/positioned/showed/hid/destroyed the view. This PRD (400)
|
|
10
|
-
* adds real navigation: browser:navigate/back/forward/reload/stop, nav-state
|
|
11
|
-
* broadcasts, the will-navigate nav-lock exemption (index.cjs allows this
|
|
12
|
-
* view's webContents to navigate anywhere while the main window stays
|
|
13
|
-
* locked), external-link routing, and download blocking. Bounds reporting
|
|
14
|
-
* from a ResizeObserver remains out of scope (PRD 401).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const path = require('path');
|
|
18
|
-
const os = require('os');
|
|
19
|
-
const fs = require('fs');
|
|
20
|
-
const crypto = require('crypto');
|
|
21
|
-
const { WebContentsView } = require('electron');
|
|
22
|
-
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
23
|
-
const { readJson, writeJson } = require('./config.cjs');
|
|
24
|
-
const {
|
|
25
|
-
parseKeyCombo,
|
|
26
|
-
computeScrollDelta,
|
|
27
|
-
computeScreenshotScale,
|
|
28
|
-
} = require('./lib/browserAgentActions.cjs');
|
|
29
|
-
|
|
30
|
-
// ── History + zoom persistence (PRD 402) ──────────────────────────────
|
|
31
|
-
// Both files live under ~/.claude/session-manager/browser/, inside
|
|
32
|
-
// config.cjs's WRITE_PREFIXES allowlist, and are written through
|
|
33
|
-
// config.cjs's writeJson (tmp+rename atomic write) — never re-implemented.
|
|
34
|
-
const BROWSER_DATA_DIR = path.join(os.homedir(), '.claude', 'session-manager', 'browser');
|
|
35
|
-
const HISTORY_PATH = path.join(BROWSER_DATA_DIR, 'history.json');
|
|
36
|
-
const ZOOM_PATH = path.join(BROWSER_DATA_DIR, 'zoom.json');
|
|
37
|
-
const HISTORY_MAX = 500;
|
|
38
|
-
|
|
39
|
-
// ── Identity-provider OAuth popup allowlist ───────────────────────────
|
|
40
|
-
// window.open() popups from these hosts get a REAL child BrowserWindow
|
|
41
|
-
// (setWindowOpenHandler returns `allow`) instead of the default deny —
|
|
42
|
-
// Google's GIS/FedCM handshake (and equivalent flows from other providers)
|
|
43
|
-
// needs a genuine window.opener to postMessage the credential back to.
|
|
44
|
-
// Every other popup keeps the existing deny + browser:open-tab-request
|
|
45
|
-
// broadcast. Add appleid.apple.com / login.microsoftonline.com / github.com
|
|
46
|
-
// here when those flows need the same treatment — no restructuring needed.
|
|
47
|
-
const IDENTITY_PROVIDER_HOSTS = ['accounts.google.com'];
|
|
48
|
-
|
|
49
|
-
function isIdentityProviderUrl(rawUrl) {
|
|
50
|
-
try {
|
|
51
|
-
return IDENTITY_PROVIDER_HOSTS.includes(new URL(rawUrl).hostname);
|
|
52
|
-
} catch {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// A realistic desktop Chrome UA built from this Electron build's OWN
|
|
58
|
-
// Chromium version (process.versions.chrome) rather than a hardcoded string
|
|
59
|
-
// that drifts stale — OAuth endpoints can flag Electron's default UA
|
|
60
|
-
// ("… Electron/x.y.z …") under a disallowed_useragent policy for embedded
|
|
61
|
-
// webviews.
|
|
62
|
-
function buildChromeUserAgent() {
|
|
63
|
-
const chromeVersion = process.versions.chrome;
|
|
64
|
-
const platformToken = process.platform === 'win32'
|
|
65
|
-
? 'Windows NT 10.0; Win64; x64'
|
|
66
|
-
: process.platform === 'darwin'
|
|
67
|
-
? 'Macintosh; Intel Mac OS X 10_15_7'
|
|
68
|
-
: 'X11; Linux x86_64';
|
|
69
|
-
return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
let lastZoomFactor = 1;
|
|
73
|
-
// Startup-only, fixed constant path (not user input) — a plain sync read is
|
|
74
|
-
// simpler than threading an async load through registerBrowserView's callers.
|
|
75
|
-
function loadPersistedZoom() {
|
|
76
|
-
try {
|
|
77
|
-
const raw = fs.readFileSync(ZOOM_PATH, 'utf8');
|
|
78
|
-
const data = JSON.parse(raw);
|
|
79
|
-
if (data && typeof data.factor === 'number' && Number.isFinite(data.factor)) {
|
|
80
|
-
lastZoomFactor = data.factor;
|
|
81
|
-
}
|
|
82
|
-
} catch {
|
|
83
|
-
// no persisted zoom yet — keep default 1
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Serializes read-modify-write cycles against history.json so concurrent
|
|
88
|
-
// navigations across multiple sub-tabs can't race and drop entries.
|
|
89
|
-
let historyWriteChain = Promise.resolve();
|
|
90
|
-
function appendHistoryEntry({ url, title }) {
|
|
91
|
-
if (!url || url === 'about:blank') return;
|
|
92
|
-
historyWriteChain = historyWriteChain.then(async () => {
|
|
93
|
-
try {
|
|
94
|
-
const existing = await readJson(HISTORY_PATH);
|
|
95
|
-
const list = Array.isArray(existing.data) ? existing.data : [];
|
|
96
|
-
list.unshift({ url, title: title || '', ts: Date.now() });
|
|
97
|
-
await writeJson(HISTORY_PATH, list.slice(0, HISTORY_MAX));
|
|
98
|
-
} catch {
|
|
99
|
-
// best-effort — history is a convenience, not load-bearing
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** @type {Map<string, WebContentsView>} */
|
|
105
|
-
const views = new Map();
|
|
106
|
-
// webContents.id of every browser-view's webContents — index.cjs's global
|
|
107
|
-
// will-navigate handler consults this set to exempt browser views from the
|
|
108
|
-
// main-window nav lock without weakening the lock itself.
|
|
109
|
-
const browserViewContentsIds = new Set();
|
|
110
|
-
let win = null;
|
|
111
|
-
// Last viewId shown via show() — the renderer's proxy for "which sub-tab is
|
|
112
|
-
// active", since WebContentsView exposes setVisible() but no getter. Used to
|
|
113
|
-
// default viewId-optional browser-agent-server routes to the active tab.
|
|
114
|
-
let lastShownViewId = null;
|
|
115
|
-
|
|
116
|
-
// ── Recorder engine (PRD 408) ─────────────────────────────────────────
|
|
117
|
-
// viewId <-> webContents.id so the record-event channel (sent from the
|
|
118
|
-
// page's isolated-preload bridge, which only knows its own sender) can be
|
|
119
|
-
// routed back to the right viewId's step stream.
|
|
120
|
-
const contentsIdToViewId = new Map();
|
|
121
|
-
const recordingViewIds = new Set();
|
|
122
|
-
const stepCounters = new Map();
|
|
123
|
-
// contextBridge.exposeInMainWorld puts the recorder toggle in the EMBEDDED
|
|
124
|
-
// PAGE's own JS context — an adversarial site being recorded could call it
|
|
125
|
-
// directly. The actual step-forwarding gate is `recordingViewIds` (only this
|
|
126
|
-
// module can set it), so a rogue page can't forge/exfiltrate anything, but it
|
|
127
|
-
// could still pause a legitimate session out from under the user. Close that
|
|
128
|
-
// with a per-view secret the page can't read: generated here, handed to the
|
|
129
|
-
// preload via `additionalArguments`/`process.argv` (never touches the DOM or
|
|
130
|
-
// a script tag the page could inspect), and required on every toggle call.
|
|
131
|
-
const recordTokens = new Map();
|
|
132
|
-
|
|
133
|
-
// viewId -> last search text passed to findInPage, so repeat calls with the
|
|
134
|
-
// same text advance to the next/previous match (findNext: true) while a new
|
|
135
|
-
// search term restarts the highlight pass (findNext: false).
|
|
136
|
-
const lastFindText = new Map();
|
|
137
|
-
|
|
138
|
-
function emitRecordStep(viewId, partial) {
|
|
139
|
-
const n = (stepCounters.get(viewId) || 0) + 1;
|
|
140
|
-
stepCounters.set(viewId, n);
|
|
141
|
-
sendIfAlive(win, `browser:record-step:${viewId}`, { n, ...partial });
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function attachWindow(mainWindow) {
|
|
145
|
-
win = mainWindow;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function isBrowserViewContents(id) {
|
|
149
|
-
return browserViewContentsIds.has(id);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function normalizeUrl(rawUrl) {
|
|
153
|
-
let url = String(rawUrl || '').trim();
|
|
154
|
-
if (!url) return { ok: false, error: 'empty url' };
|
|
155
|
-
if (/^https?:\/\//i.test(url)) {
|
|
156
|
-
// already has an allowed scheme
|
|
157
|
-
} else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
158
|
-
// has some other scheme with an authority (file://, chrome://, etc.) — reject
|
|
159
|
-
return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
|
|
160
|
-
} else if (/^javascript:/i.test(url) || /^data:/i.test(url)) {
|
|
161
|
-
// schemes with no "//" authority that are still dangerous — reject explicitly
|
|
162
|
-
return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
|
|
163
|
-
} else {
|
|
164
|
-
// bare host[:port]/path — assume https
|
|
165
|
-
url = `https://${url}`;
|
|
166
|
-
}
|
|
167
|
-
if (!/^https:\/\//i.test(url) && !/^http:\/\//i.test(url)) {
|
|
168
|
-
return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
|
|
169
|
-
}
|
|
170
|
-
// Upgrade http:// to https:// per AC (loadURL still allowed to redirect
|
|
171
|
-
// back down if the site truly has no https, but we always request https first).
|
|
172
|
-
if (/^http:\/\//i.test(url)) {
|
|
173
|
-
url = `https://${url.slice('http://'.length)}`;
|
|
174
|
-
}
|
|
175
|
-
return { ok: true, url };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function broadcastNavState(viewId, view) {
|
|
179
|
-
if (!view || view.webContents.isDestroyed()) return;
|
|
180
|
-
const wc = view.webContents;
|
|
181
|
-
const url = wc.getURL();
|
|
182
|
-
sendIfAlive(win, `browser:nav-state:${viewId}`, {
|
|
183
|
-
url,
|
|
184
|
-
title: wc.getTitle(),
|
|
185
|
-
canGoBack: wc.navigationHistory ? wc.navigationHistory.canGoBack() : wc.canGoBack(),
|
|
186
|
-
canGoForward: wc.navigationHistory ? wc.navigationHistory.canGoForward() : wc.canGoForward(),
|
|
187
|
-
loading: wc.isLoading(),
|
|
188
|
-
isSecure: /^https:/i.test(url),
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function wireNavEvents(viewId, view) {
|
|
193
|
-
const wc = view.webContents;
|
|
194
|
-
const emit = () => broadcastNavState(viewId, view);
|
|
195
|
-
|
|
196
|
-
wc.on('did-start-navigation', emit);
|
|
197
|
-
wc.on('did-navigate', (_e, url) => {
|
|
198
|
-
emit();
|
|
199
|
-
// wc.getTitle() here still reports the PREVIOUS page's title — the new
|
|
200
|
-
// page's <title> hasn't parsed yet. Wait one tick for dom-ready so the
|
|
201
|
-
// history entry gets the title of the page actually navigated to.
|
|
202
|
-
wc.once('dom-ready', () => appendHistoryEntry({ url, title: wc.getTitle() }));
|
|
203
|
-
if (recordingViewIds.has(viewId)) emitRecordStep(viewId, { verb: 'navigate', target: url });
|
|
204
|
-
});
|
|
205
|
-
wc.on('did-navigate-in-page', emit);
|
|
206
|
-
wc.on('page-title-updated', emit);
|
|
207
|
-
wc.on('did-fail-load', (_e, errorCode) => {
|
|
208
|
-
// -3 is ERR_ABORTED — fires on routine cancelled/superseded loads, not a
|
|
209
|
-
// real failure. Skip so the renderer doesn't show a noisy error banner.
|
|
210
|
-
if (errorCode === -3) return;
|
|
211
|
-
emit();
|
|
212
|
-
});
|
|
213
|
-
// The recorder-preload's `capturing` flag lives in the page's JS context,
|
|
214
|
-
// which is torn down on every navigation — re-arm it after each load so a
|
|
215
|
-
// recording session survives the page it started on navigating away.
|
|
216
|
-
wc.on('did-finish-load', () => {
|
|
217
|
-
if (!recordingViewIds.has(viewId)) return;
|
|
218
|
-
const token = recordTokens.get(viewId);
|
|
219
|
-
wc.executeJavaScript(
|
|
220
|
-
`window.__smRecorder && window.__smRecorder.setRecording(true, ${JSON.stringify(token)})`,
|
|
221
|
-
).catch(() => {});
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
wc.setWindowOpenHandler(({ url }) => {
|
|
225
|
-
if (isIdentityProviderUrl(url)) {
|
|
226
|
-
return {
|
|
227
|
-
action: 'allow',
|
|
228
|
-
overrideBrowserWindowOptions: {
|
|
229
|
-
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
if (/^https?:\/\//i.test(url)) {
|
|
234
|
-
sendIfAlive(win, 'browser:open-tab-request', { url });
|
|
235
|
-
}
|
|
236
|
-
return { action: 'deny' };
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
// Fires only when setWindowOpenHandler above returned `allow` — i.e. only
|
|
240
|
-
// for identity-provider popups. Sets a real Chrome UA on the popup (same
|
|
241
|
-
// reasoning as the main view below) and registers its webContents.id in
|
|
242
|
-
// browserViewContentsIds so index.cjs's global will-navigate lock exempts
|
|
243
|
-
// it too — an OAuth popup needs to navigate through several redirects
|
|
244
|
-
// (consent screen, callback) exactly like the main Browser view already
|
|
245
|
-
// does, otherwise the very handshake this fix enables would immediately
|
|
246
|
-
// get nav-locked shut. Cleaned up on the popup's own 'destroyed' event,
|
|
247
|
-
// following create()'s `view.webContents.once('destroyed', ...)` pattern
|
|
248
|
-
// so nothing dangles once the popup closes.
|
|
249
|
-
wc.on('did-create-window', (childWindow) => {
|
|
250
|
-
const childWc = childWindow.webContents;
|
|
251
|
-
childWc.setUserAgent(buildChromeUserAgent());
|
|
252
|
-
browserViewContentsIds.add(childWc.id);
|
|
253
|
-
childWc.once('destroyed', () => {
|
|
254
|
-
browserViewContentsIds.delete(childWc.id);
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
wc.session.on('will-download', (event) => {
|
|
259
|
-
event.preventDefault();
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
wc.on('found-in-page', (_e, result) => {
|
|
263
|
-
sendIfAlive(win, `browser:find-result:${viewId}`, {
|
|
264
|
-
requestId: result.requestId,
|
|
265
|
-
matches: result.matches,
|
|
266
|
-
activeMatchOrdinal: result.activeMatchOrdinal,
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function create({ viewId, partition }) {
|
|
272
|
-
if (views.has(viewId)) return { ok: true };
|
|
273
|
-
const recordToken = crypto.randomBytes(16).toString('hex');
|
|
274
|
-
recordTokens.set(viewId, recordToken);
|
|
275
|
-
const view = new WebContentsView({
|
|
276
|
-
webPreferences: {
|
|
277
|
-
contextIsolation: true,
|
|
278
|
-
nodeIntegration: false,
|
|
279
|
-
sandbox: true,
|
|
280
|
-
partition,
|
|
281
|
-
preload: path.join(__dirname, '..', 'preload', 'browserViewPreload.cjs'),
|
|
282
|
-
additionalArguments: [`--sm-record-token=${recordToken}`],
|
|
283
|
-
},
|
|
284
|
-
});
|
|
285
|
-
views.set(viewId, view);
|
|
286
|
-
const wcId = view.webContents.id;
|
|
287
|
-
browserViewContentsIds.add(wcId);
|
|
288
|
-
contentsIdToViewId.set(wcId, viewId);
|
|
289
|
-
view.webContents.setZoomFactor(lastZoomFactor);
|
|
290
|
-
view.webContents.setUserAgent(buildChromeUserAgent());
|
|
291
|
-
wireNavEvents(viewId, view);
|
|
292
|
-
view.webContents.once('destroyed', () => {
|
|
293
|
-
browserViewContentsIds.delete(wcId);
|
|
294
|
-
contentsIdToViewId.delete(wcId);
|
|
295
|
-
recordingViewIds.delete(viewId);
|
|
296
|
-
stepCounters.delete(viewId);
|
|
297
|
-
recordTokens.delete(viewId);
|
|
298
|
-
lastFindText.delete(viewId);
|
|
299
|
-
});
|
|
300
|
-
if (win && !win.isDestroyed()) {
|
|
301
|
-
win.contentView.addChildView(view);
|
|
302
|
-
}
|
|
303
|
-
return { ok: true };
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function setBounds({ viewId, x, y, width, height }) {
|
|
307
|
-
const view = views.get(viewId);
|
|
308
|
-
if (!view) return { ok: false };
|
|
309
|
-
view.setBounds({
|
|
310
|
-
x: Math.round(x),
|
|
311
|
-
y: Math.round(y),
|
|
312
|
-
width: Math.round(width),
|
|
313
|
-
height: Math.round(height),
|
|
314
|
-
});
|
|
315
|
-
return { ok: true };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function show({ viewId }) {
|
|
319
|
-
const view = views.get(viewId);
|
|
320
|
-
if (!view) return { ok: false };
|
|
321
|
-
view.setVisible(true);
|
|
322
|
-
lastShownViewId = viewId;
|
|
323
|
-
return { ok: true };
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function hide({ viewId }) {
|
|
327
|
-
const view = views.get(viewId);
|
|
328
|
-
if (!view) return { ok: false };
|
|
329
|
-
view.setVisible(false);
|
|
330
|
-
return { ok: true };
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function destroy({ viewId }) {
|
|
334
|
-
const view = views.get(viewId);
|
|
335
|
-
if (!view) return { ok: true };
|
|
336
|
-
browserViewContentsIds.delete(view.webContents.id);
|
|
337
|
-
contentsIdToViewId.delete(view.webContents.id);
|
|
338
|
-
recordingViewIds.delete(viewId);
|
|
339
|
-
stepCounters.delete(viewId);
|
|
340
|
-
lastFindText.delete(viewId);
|
|
341
|
-
if (lastShownViewId === viewId) lastShownViewId = null;
|
|
342
|
-
if (win && !win.isDestroyed()) {
|
|
343
|
-
try { win.contentView.removeChildView(view); } catch { /* already detached */ }
|
|
344
|
-
}
|
|
345
|
-
try { view.webContents.close(); } catch { /* already closed */ }
|
|
346
|
-
views.delete(viewId);
|
|
347
|
-
return { ok: true };
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// PRD 402: address-bar zoom control. Factor is clamped to Chromium's own
|
|
351
|
-
// findInPage-adjacent zoom range and persisted so the next-opened tab (and
|
|
352
|
-
// next app launch, via loadPersistedZoom) starts at the last value used.
|
|
353
|
-
function setZoom({ viewId, factor }) {
|
|
354
|
-
const view = views.get(viewId);
|
|
355
|
-
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
356
|
-
const clamped = Math.min(3, Math.max(0.25, Number(factor) || 1));
|
|
357
|
-
view.webContents.setZoomFactor(clamped);
|
|
358
|
-
lastZoomFactor = clamped;
|
|
359
|
-
writeJson(ZOOM_PATH, { factor: clamped }).catch(() => {});
|
|
360
|
-
return { ok: true, factor: clamped };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// PRD 402: Cmd/Ctrl+F find bar. Empty text clears the search instead of
|
|
364
|
-
// running an empty findInPage query (Electron throws on '').
|
|
365
|
-
function find({ viewId, text, forward }) {
|
|
366
|
-
const view = views.get(viewId);
|
|
367
|
-
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
368
|
-
if (!text) {
|
|
369
|
-
view.webContents.stopFindInPage('clearSelection');
|
|
370
|
-
lastFindText.delete(viewId);
|
|
371
|
-
return { ok: true };
|
|
372
|
-
}
|
|
373
|
-
const isRepeat = lastFindText.get(viewId) === text;
|
|
374
|
-
lastFindText.set(viewId, text);
|
|
375
|
-
view.webContents.findInPage(text, { forward: forward !== false, findNext: isRepeat });
|
|
376
|
-
return { ok: true };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function stopFind({ viewId }) {
|
|
380
|
-
const view = views.get(viewId);
|
|
381
|
-
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
382
|
-
view.webContents.stopFindInPage('clearSelection');
|
|
383
|
-
lastFindText.delete(viewId);
|
|
384
|
-
return { ok: true };
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function recordStart({ viewId }) {
|
|
388
|
-
const view = views.get(viewId);
|
|
389
|
-
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
390
|
-
recordingViewIds.add(viewId);
|
|
391
|
-
stepCounters.set(viewId, 0);
|
|
392
|
-
const token = recordTokens.get(viewId);
|
|
393
|
-
view.webContents
|
|
394
|
-
.executeJavaScript(`window.__smRecorder && window.__smRecorder.setRecording(true, ${JSON.stringify(token)})`)
|
|
395
|
-
.catch(() => {});
|
|
396
|
-
return { ok: true };
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function recordStop({ viewId }) {
|
|
400
|
-
recordingViewIds.delete(viewId);
|
|
401
|
-
const view = views.get(viewId);
|
|
402
|
-
if (view && !view.webContents.isDestroyed()) {
|
|
403
|
-
const token = recordTokens.get(viewId);
|
|
404
|
-
view.webContents
|
|
405
|
-
.executeJavaScript(`window.__smRecorder && window.__smRecorder.setRecording(false, ${JSON.stringify(token)})`)
|
|
406
|
-
.catch(() => {});
|
|
407
|
-
}
|
|
408
|
-
return { ok: true };
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Sent by the recorder-preload's contextBridge (never the page itself — the
|
|
412
|
-
// page has no node/ipcRenderer access under sandbox+contextIsolation). Only
|
|
413
|
-
// forwarded while the view is actually in a recording session, and only the
|
|
414
|
-
// two verbs the preload emits; typed values are never included (privacy
|
|
415
|
-
// invariant — "sandboxed · no filesystem · no passwords").
|
|
416
|
-
function handleRecordEvent(event, payload) {
|
|
417
|
-
const viewId = contentsIdToViewId.get(event.sender.id);
|
|
418
|
-
if (!viewId || !recordingViewIds.has(viewId)) return;
|
|
419
|
-
const verb = payload && payload.verb;
|
|
420
|
-
if (verb !== 'click' && verb !== 'type' && verb !== 'drag') return;
|
|
421
|
-
const target = typeof payload.target === 'string' ? payload.target.slice(0, 300) : '';
|
|
422
|
-
const step = { verb, target };
|
|
423
|
-
if (verb === 'type') {
|
|
424
|
-
step.masked = true;
|
|
425
|
-
if (typeof payload.variableSuggestion === 'string') {
|
|
426
|
-
step.variableSuggestion = payload.variableSuggestion.slice(0, 64);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
if (verb === 'click') {
|
|
430
|
-
const x = Number(payload.x);
|
|
431
|
-
const y = Number(payload.y);
|
|
432
|
-
if (Number.isFinite(x)) step.x = x;
|
|
433
|
-
if (Number.isFinite(y)) step.y = y;
|
|
434
|
-
}
|
|
435
|
-
if (verb === 'drag') {
|
|
436
|
-
if (typeof payload.endTarget === 'string') step.endTarget = payload.endTarget.slice(0, 300);
|
|
437
|
-
for (const key of ['x', 'y', 'endX', 'endY']) {
|
|
438
|
-
const n = Number(payload[key]);
|
|
439
|
-
if (Number.isFinite(n)) step[key] = n;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
emitRecordStep(viewId, step);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// ── Replay engine (PRD 410) ───────────────────────────────────────────
|
|
446
|
-
// Each step is bounded so a bad/stale selector can't hang the run; the
|
|
447
|
-
// caller supplies `steps` (renderer owns the recorded step list — the main
|
|
448
|
-
// process never persists them), so replay is stateless between calls.
|
|
449
|
-
const REPLAY_STEP_TIMEOUT_MS = 10_000;
|
|
450
|
-
const REPLAY_WAIT_POLL_MS = 250;
|
|
451
|
-
|
|
452
|
-
function withTimeout(promise, ms, label) {
|
|
453
|
-
let timer;
|
|
454
|
-
const timeout = new Promise((_resolve, reject) => {
|
|
455
|
-
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
456
|
-
});
|
|
457
|
-
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
async function replayPositionedClick(wc, x, y) {
|
|
461
|
-
wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
|
|
462
|
-
wc.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
async function replayClickOrType(wc, step, values) {
|
|
466
|
-
if (step.verb === 'click' && Number.isFinite(step.x) && Number.isFinite(step.y)) {
|
|
467
|
-
await replayPositionedClick(wc, step.x, step.y);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
const script =
|
|
471
|
-
step.verb === 'type'
|
|
472
|
-
? `(() => {
|
|
473
|
-
const el = document.querySelector(${JSON.stringify(step.target)});
|
|
474
|
-
if (!el) return false;
|
|
475
|
-
el.focus();
|
|
476
|
-
// React (and similar frameworks) override the native value setter
|
|
477
|
-
// to track state; assigning el.value directly leaves that tracked
|
|
478
|
-
// state stale, so the framework's onChange never fires. Go through
|
|
479
|
-
// the native prototype setter — the same workaround React Testing
|
|
480
|
-
// Library / Playwright use — so the dispatched 'input' event is
|
|
481
|
-
// seen as a real change.
|
|
482
|
-
const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
|
483
|
-
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
484
|
-
if (nativeSetter && nativeSetter.set) {
|
|
485
|
-
nativeSetter.set.call(el, ${JSON.stringify(step.variable && values[step.variable] !== undefined ? String(values[step.variable]) : '')});
|
|
486
|
-
} else {
|
|
487
|
-
el.value = ${JSON.stringify(step.variable && values[step.variable] !== undefined ? String(values[step.variable]) : '')};
|
|
488
|
-
}
|
|
489
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
490
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
491
|
-
return true;
|
|
492
|
-
})()`
|
|
493
|
-
: step.verb === 'select'
|
|
494
|
-
? `(() => {
|
|
495
|
-
const el = document.querySelector(${JSON.stringify(step.target)});
|
|
496
|
-
if (!el) return false;
|
|
497
|
-
el.value = ${JSON.stringify(step.value ?? '')};
|
|
498
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
499
|
-
return true;
|
|
500
|
-
})()`
|
|
501
|
-
: `(() => {
|
|
502
|
-
const el = document.querySelector(${JSON.stringify(step.target)});
|
|
503
|
-
if (!el) return false;
|
|
504
|
-
el.click();
|
|
505
|
-
return true;
|
|
506
|
-
})()`;
|
|
507
|
-
const found = await withTimeout(wc.executeJavaScript(script), REPLAY_STEP_TIMEOUT_MS, step.verb);
|
|
508
|
-
if (!found) throw new Error(`selector not found: ${step.target}`);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
async function replayWaitFor(wc, step) {
|
|
512
|
-
const target = step.target;
|
|
513
|
-
const deadline = Date.now() + REPLAY_STEP_TIMEOUT_MS;
|
|
514
|
-
const checkScript = `(() => {
|
|
515
|
-
try {
|
|
516
|
-
return (document.body && document.body.innerText.includes(${JSON.stringify(target)})) || location.href.includes(${JSON.stringify(target)});
|
|
517
|
-
} catch { return false; }
|
|
518
|
-
})()`;
|
|
519
|
-
for (;;) {
|
|
520
|
-
const found = await wc.executeJavaScript(checkScript).catch(() => false);
|
|
521
|
-
if (found) return;
|
|
522
|
-
if (Date.now() >= deadline) throw new Error(`wait-for timed out: ${target}`);
|
|
523
|
-
await new Promise((r) => setTimeout(r, REPLAY_WAIT_POLL_MS));
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
const REPLAY_DRAG_STEPS = 3;
|
|
528
|
-
|
|
529
|
-
async function replayDrag(wc, step) {
|
|
530
|
-
const { x, y, endX, endY } = step;
|
|
531
|
-
if (![x, y, endX, endY].every((n) => Number.isFinite(n))) {
|
|
532
|
-
throw new Error('drag step missing coordinates');
|
|
533
|
-
}
|
|
534
|
-
wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
|
|
535
|
-
for (let i = 1; i <= REPLAY_DRAG_STEPS; i += 1) {
|
|
536
|
-
const t = i / REPLAY_DRAG_STEPS;
|
|
537
|
-
wc.sendInputEvent({
|
|
538
|
-
type: 'mouseMove',
|
|
539
|
-
x: Math.round(x + (endX - x) * t),
|
|
540
|
-
y: Math.round(y + (endY - y) * t),
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
wc.sendInputEvent({ type: 'mouseUp', x: endX, y: endY, button: 'left', clickCount: 1 });
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
async function replayStep(view, step, values) {
|
|
547
|
-
const wc = view.webContents;
|
|
548
|
-
if (step.verb === 'navigate') {
|
|
549
|
-
const normalized = normalizeUrl(step.target);
|
|
550
|
-
if (!normalized.ok) throw new Error(normalized.error);
|
|
551
|
-
await withTimeout(wc.loadURL(normalized.url), REPLAY_STEP_TIMEOUT_MS, 'navigate');
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
if (step.verb === 'click' || step.verb === 'type' || step.verb === 'select') {
|
|
555
|
-
await replayClickOrType(wc, step, values);
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
if (step.verb === 'wait-for') {
|
|
559
|
-
await replayWaitFor(wc, step);
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
if (step.verb === 'drag') {
|
|
563
|
-
await withTimeout(replayDrag(wc, step), REPLAY_STEP_TIMEOUT_MS, 'drag');
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
throw new Error(`unsupported step verb: ${step.verb}`);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// `continueOnError` defaults to false: replay stops at the first failed step
|
|
570
|
-
// (fail-fast, matches the transport's "pass/fail per step" model) — the
|
|
571
|
-
// caller can opt into running every step regardless by passing `true`.
|
|
572
|
-
//
|
|
573
|
-
// Replayed navigations/clicks/types are synthetic but indistinguishable from
|
|
574
|
-
// real user input to the recorder's own listeners (did-navigate,
|
|
575
|
-
// browser:record-event) — without suppressing capture here, replaying a
|
|
576
|
-
// session that's still "recording" (armed until stop, not just paused; see
|
|
577
|
-
// browser.ts toggleRecordingPause) would re-append every replayed step back
|
|
578
|
-
// into the step list. Drop the viewId out of recordingViewIds for the
|
|
579
|
-
// duration of the run and restore its prior membership afterward.
|
|
580
|
-
async function replay({ viewId, steps, values, continueOnError }) {
|
|
581
|
-
const view = views.get(viewId);
|
|
582
|
-
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
583
|
-
const stepList = Array.isArray(steps) ? steps : [];
|
|
584
|
-
const resolvedValues = values || {};
|
|
585
|
-
const wasRecording = recordingViewIds.has(viewId);
|
|
586
|
-
recordingViewIds.delete(viewId);
|
|
587
|
-
try {
|
|
588
|
-
for (const step of stepList) {
|
|
589
|
-
try {
|
|
590
|
-
await replayStep(view, step, resolvedValues);
|
|
591
|
-
sendIfAlive(win, `browser:replay-step:${viewId}`, { n: step.n, status: 'pass' });
|
|
592
|
-
} catch (e) {
|
|
593
|
-
const detail = e && e.message ? e.message : String(e);
|
|
594
|
-
sendIfAlive(win, `browser:replay-step:${viewId}`, { n: step.n, status: 'fail', detail });
|
|
595
|
-
if (!continueOnError) return { ok: true, stopped: true, failedAt: step.n };
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
return { ok: true, stopped: false };
|
|
599
|
-
} finally {
|
|
600
|
-
if (wasRecording) recordingViewIds.add(viewId);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
function navigate({ viewId, url }) {
|
|
605
|
-
const view = views.get(viewId);
|
|
606
|
-
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
607
|
-
const normalized = normalizeUrl(url);
|
|
608
|
-
if (!normalized.ok) return { ok: false, error: normalized.error };
|
|
609
|
-
view.webContents.loadURL(normalized.url).catch(() => {
|
|
610
|
-
// Load failures surface via did-fail-load → broadcastNavState; nothing
|
|
611
|
-
// further to do here.
|
|
612
|
-
});
|
|
613
|
-
return { ok: true };
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
function back({ viewId }) {
|
|
617
|
-
const view = views.get(viewId);
|
|
618
|
-
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
619
|
-
const wc = view.webContents;
|
|
620
|
-
const nav = wc.navigationHistory;
|
|
621
|
-
const canGoBack = nav ? nav.canGoBack() : wc.canGoBack();
|
|
622
|
-
if (!canGoBack) return { ok: false, error: 'cannot go back' };
|
|
623
|
-
if (nav) nav.goBack(); else wc.goBack();
|
|
624
|
-
return { ok: true };
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function forward({ viewId }) {
|
|
628
|
-
const view = views.get(viewId);
|
|
629
|
-
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
630
|
-
const wc = view.webContents;
|
|
631
|
-
const nav = wc.navigationHistory;
|
|
632
|
-
const canGoForward = nav ? nav.canGoForward() : wc.canGoForward();
|
|
633
|
-
if (!canGoForward) return { ok: false, error: 'cannot go forward' };
|
|
634
|
-
if (nav) nav.goForward(); else wc.goForward();
|
|
635
|
-
return { ok: true };
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
function reload({ viewId }) {
|
|
639
|
-
const view = views.get(viewId);
|
|
640
|
-
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
641
|
-
view.webContents.reload();
|
|
642
|
-
return { ok: true };
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
function stop({ viewId }) {
|
|
646
|
-
const view = views.get(viewId);
|
|
647
|
-
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
648
|
-
view.webContents.stop();
|
|
649
|
-
return { ok: true };
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// ── On-demand single-action agent API (PRD 535 foundation) ────────────
|
|
653
|
-
// Modeled on Anthropic's published computer_use tool schema. Reuses the same
|
|
654
|
-
// dispatch primitives the Recorder replay engine already established above
|
|
655
|
-
// (replayPositionedClick for coordinate clicks, normalizeUrl/withTimeout for
|
|
656
|
-
// navigate) instead of forking a second copy — the difference from replay()
|
|
657
|
-
// is that this drives ONE ad-hoc action at a time by raw coordinate, not a
|
|
658
|
-
// recorded `steps` array by selector.
|
|
659
|
-
function listViews() {
|
|
660
|
-
const result = [];
|
|
661
|
-
for (const [viewId, view] of views.entries()) {
|
|
662
|
-
if (view.webContents.isDestroyed()) continue;
|
|
663
|
-
result.push({
|
|
664
|
-
viewId,
|
|
665
|
-
url: view.webContents.getURL(),
|
|
666
|
-
title: view.webContents.getTitle(),
|
|
667
|
-
active: viewId === lastShownViewId,
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
return result;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
function resolveViewId(viewId) {
|
|
674
|
-
return viewId || lastShownViewId;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
async function dispatchAction(view, params) {
|
|
678
|
-
const wc = view.webContents;
|
|
679
|
-
switch (params.action) {
|
|
680
|
-
case 'left_click': {
|
|
681
|
-
const [x, y] = Array.isArray(params.coordinate) ? params.coordinate : [];
|
|
682
|
-
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
683
|
-
throw new Error('left_click requires coordinate: [x, y]');
|
|
684
|
-
}
|
|
685
|
-
await replayPositionedClick(wc, x, y);
|
|
686
|
-
return { ok: true };
|
|
687
|
-
}
|
|
688
|
-
case 'type': {
|
|
689
|
-
if (typeof params.text !== 'string') throw new Error('type requires text');
|
|
690
|
-
await wc.insertText(params.text);
|
|
691
|
-
return { ok: true };
|
|
692
|
-
}
|
|
693
|
-
case 'key': {
|
|
694
|
-
if (typeof params.key !== 'string' || !params.key) throw new Error('key requires key');
|
|
695
|
-
const { keyCode, modifiers } = parseKeyCombo(params.key);
|
|
696
|
-
wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers });
|
|
697
|
-
if (keyCode.length === 1) wc.sendInputEvent({ type: 'char', keyCode, modifiers });
|
|
698
|
-
wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers });
|
|
699
|
-
return { ok: true };
|
|
700
|
-
}
|
|
701
|
-
case 'scroll': {
|
|
702
|
-
const [x, y] = Array.isArray(params.coordinate) ? params.coordinate : [0, 0];
|
|
703
|
-
const { deltaX, deltaY } = computeScrollDelta(params);
|
|
704
|
-
wc.sendInputEvent({ type: 'mouseWheel', x: Number(x) || 0, y: Number(y) || 0, deltaX, deltaY });
|
|
705
|
-
return { ok: true };
|
|
706
|
-
}
|
|
707
|
-
case 'navigate': {
|
|
708
|
-
if (typeof params.url !== 'string' || !params.url) throw new Error('navigate requires url');
|
|
709
|
-
const normalized = normalizeUrl(params.url);
|
|
710
|
-
if (!normalized.ok) throw new Error(normalized.error);
|
|
711
|
-
await withTimeout(wc.loadURL(normalized.url), REPLAY_STEP_TIMEOUT_MS, 'navigate');
|
|
712
|
-
return { ok: true };
|
|
713
|
-
}
|
|
714
|
-
default:
|
|
715
|
-
throw new Error(`unsupported action: ${params.action}`);
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// Single-action entry point (as opposed to replay()'s recorded-steps-array
|
|
720
|
-
// entry point) — resolves viewId (defaulting to the active tab), dispatches
|
|
721
|
-
// exactly one action, and normalizes errors into the { ok, error } shape the
|
|
722
|
-
// rest of this module already uses.
|
|
723
|
-
async function dispatchViewAction(params) {
|
|
724
|
-
const viewId = resolveViewId(params.viewId);
|
|
725
|
-
if (!viewId) return { ok: false, error: 'no viewId given and no active tab' };
|
|
726
|
-
const view = views.get(viewId);
|
|
727
|
-
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
728
|
-
try {
|
|
729
|
-
return await dispatchAction(view, params);
|
|
730
|
-
} catch (e) {
|
|
731
|
-
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// On-demand screenshot for the agent API. Reuses wc.capturePage() (same call
|
|
736
|
-
// captureShot() below already uses) but additionally resizes to a fixed
|
|
737
|
-
// logical resolution — Anthropic's computer-use API silently downscales
|
|
738
|
-
// oversized screenshots server-side, which breaks coordinate mapping unless
|
|
739
|
-
// caller and model agree on one fixed max dimension. `scale` in the response
|
|
740
|
-
// lets a caller map a coordinate clicked on the returned image back to the
|
|
741
|
-
// real page: realX = clickedX / scale.
|
|
742
|
-
async function captureShotForAgent({ viewId } = {}) {
|
|
743
|
-
const id = resolveViewId(viewId);
|
|
744
|
-
if (!id) return { ok: false, error: 'no viewId given and no active tab' };
|
|
745
|
-
const view = views.get(id);
|
|
746
|
-
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
747
|
-
try {
|
|
748
|
-
const wc = view.webContents;
|
|
749
|
-
const image = await wc.capturePage();
|
|
750
|
-
const { width, height } = image.getSize();
|
|
751
|
-
const target = computeScreenshotScale(width, height);
|
|
752
|
-
const resized = target.scale < 1 ? image.resize({ width: target.width, height: target.height }) : image;
|
|
753
|
-
return {
|
|
754
|
-
ok: true,
|
|
755
|
-
viewId: id,
|
|
756
|
-
url: wc.getURL(),
|
|
757
|
-
title: wc.getTitle(),
|
|
758
|
-
dataUrl: resized.toDataURL(),
|
|
759
|
-
scale: target.scale,
|
|
760
|
-
width: target.width,
|
|
761
|
-
height: target.height,
|
|
762
|
-
originalWidth: width,
|
|
763
|
-
originalHeight: height,
|
|
764
|
-
};
|
|
765
|
-
} catch (e) {
|
|
766
|
-
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// Cheap text/accessibility-style snapshot for the agent API — an alternative
|
|
771
|
-
// to vision-only driving since a real DOM is available here. Follows the
|
|
772
|
-
// same executeJavaScript sandboxing pattern as captureDom() below (plain
|
|
773
|
-
// IIFE returning a plain object, no new capability granted to the page).
|
|
774
|
-
// Kept cheap on purpose: innerText + a capped list of interactive elements
|
|
775
|
-
// with center-point coordinates, no full HTML serialization or computed
|
|
776
|
-
// style walking.
|
|
777
|
-
const AGENT_DOM_TEXT_MAX = 100_000;
|
|
778
|
-
const AGENT_DOM_ELEMENT_LIMIT = 200;
|
|
779
|
-
|
|
780
|
-
async function captureAgentDom({ viewId } = {}) {
|
|
781
|
-
const id = resolveViewId(viewId);
|
|
782
|
-
if (!id) return { ok: false, error: 'no viewId given and no active tab' };
|
|
783
|
-
const view = views.get(id);
|
|
784
|
-
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
785
|
-
try {
|
|
786
|
-
const script = `(() => {
|
|
787
|
-
const SEL = 'button, a[href], input, textarea, select, [role="button"], [onclick]';
|
|
788
|
-
const els = Array.from(document.querySelectorAll(SEL)).slice(0, ${AGENT_DOM_ELEMENT_LIMIT});
|
|
789
|
-
const elements = els.map((el) => {
|
|
790
|
-
const rect = el.getBoundingClientRect();
|
|
791
|
-
const label = (el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '').trim().slice(0, 120);
|
|
792
|
-
return {
|
|
793
|
-
tag: el.tagName.toLowerCase(),
|
|
794
|
-
label,
|
|
795
|
-
x: Math.round(rect.left + rect.width / 2),
|
|
796
|
-
y: Math.round(rect.top + rect.height / 2),
|
|
797
|
-
};
|
|
798
|
-
}).filter((e) => e.x >= 0 && e.y >= 0 && Number.isFinite(e.x) && Number.isFinite(e.y));
|
|
799
|
-
return {
|
|
800
|
-
url: location.href,
|
|
801
|
-
title: document.title,
|
|
802
|
-
text: document.body ? document.body.innerText : '',
|
|
803
|
-
elements,
|
|
804
|
-
};
|
|
805
|
-
})()`;
|
|
806
|
-
const result = await view.webContents.executeJavaScript(script);
|
|
807
|
-
let text = typeof result?.text === 'string' ? result.text : '';
|
|
808
|
-
let truncated = false;
|
|
809
|
-
if (text.length > AGENT_DOM_TEXT_MAX) {
|
|
810
|
-
text = text.slice(0, AGENT_DOM_TEXT_MAX);
|
|
811
|
-
truncated = true;
|
|
812
|
-
}
|
|
813
|
-
return {
|
|
814
|
-
ok: true,
|
|
815
|
-
viewId: id,
|
|
816
|
-
url: result?.url || '',
|
|
817
|
-
title: result?.title || '',
|
|
818
|
-
text,
|
|
819
|
-
truncated,
|
|
820
|
-
elements: Array.isArray(result?.elements) ? result.elements : [],
|
|
821
|
-
};
|
|
822
|
-
} catch (e) {
|
|
823
|
-
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// Cap returned capture text so a huge DOM can't blow up the IPC channel.
|
|
828
|
-
const CAPTURE_TEXT_MAX = 500_000;
|
|
829
|
-
|
|
830
|
-
// PRD 407: grab page text/HTML for the Capture panel. Reuses the same
|
|
831
|
-
// executeJavaScript access path the recorder-preload re-arm already uses
|
|
832
|
-
// above (did-finish-load handler) — one round trip returns url/title/text
|
|
833
|
-
// together instead of three separate calls.
|
|
834
|
-
async function captureDom({ viewId, kind }) {
|
|
835
|
-
const view = views.get(viewId);
|
|
836
|
-
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
837
|
-
try {
|
|
838
|
-
const script = kind === 'html'
|
|
839
|
-
? '({ url: location.href, title: document.title, text: document.documentElement.outerHTML })'
|
|
840
|
-
: '({ url: location.href, title: document.title, text: document.body ? document.body.innerText : "" })';
|
|
841
|
-
const result = await view.webContents.executeJavaScript(script);
|
|
842
|
-
let text = typeof result?.text === 'string' ? result.text : '';
|
|
843
|
-
let truncated = false;
|
|
844
|
-
if (text.length > CAPTURE_TEXT_MAX) {
|
|
845
|
-
text = text.slice(0, CAPTURE_TEXT_MAX);
|
|
846
|
-
truncated = true;
|
|
847
|
-
}
|
|
848
|
-
return { ok: true, url: result?.url || '', title: result?.title || '', text, truncated };
|
|
849
|
-
} catch (e) {
|
|
850
|
-
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
// PRD 407: screenshot the active browser sub-tab as a PNG data URL.
|
|
855
|
-
async function captureShot({ viewId }) {
|
|
856
|
-
const view = views.get(viewId);
|
|
857
|
-
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
858
|
-
try {
|
|
859
|
-
const wc = view.webContents;
|
|
860
|
-
const image = await wc.capturePage();
|
|
861
|
-
const dataUrl = image.toDataURL();
|
|
862
|
-
return { ok: true, url: wc.getURL(), title: wc.getTitle(), dataUrl };
|
|
863
|
-
} catch (e) {
|
|
864
|
-
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// PRD 403: getter passed to browserCapture.cjs's registerBrowserCapture so
|
|
869
|
-
// the picker can reach a live WebContentsView without this module exposing
|
|
870
|
-
// its whole `views` Map as a mutable dependency surface.
|
|
871
|
-
function getView(viewId) {
|
|
872
|
-
return views.get(viewId);
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
function registerBrowserView({ mainWindow, ipcMain }) {
|
|
876
|
-
attachWindow(mainWindow);
|
|
877
|
-
loadPersistedZoom();
|
|
878
|
-
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
879
|
-
ipcMain.handle('browser:create', validated(schemas.browserCreate, (payload) => create(payload)));
|
|
880
|
-
ipcMain.handle('browser:set-bounds', validated(schemas.browserSetBounds, (payload) => setBounds(payload)));
|
|
881
|
-
ipcMain.handle('browser:show', validated(schemas.browserViewId, (payload) => show(payload)));
|
|
882
|
-
ipcMain.handle('browser:hide', validated(schemas.browserViewId, (payload) => hide(payload)));
|
|
883
|
-
ipcMain.handle('browser:destroy', validated(schemas.browserViewId, (payload) => destroy(payload)));
|
|
884
|
-
ipcMain.handle('browser:navigate', validated(schemas.browserNavigate, (payload) => navigate(payload)));
|
|
885
|
-
ipcMain.handle('browser:back', validated(schemas.browserViewId, (payload) => back(payload)));
|
|
886
|
-
ipcMain.handle('browser:forward', validated(schemas.browserViewId, (payload) => forward(payload)));
|
|
887
|
-
ipcMain.handle('browser:reload', validated(schemas.browserViewId, (payload) => reload(payload)));
|
|
888
|
-
ipcMain.handle('browser:stop', validated(schemas.browserViewId, (payload) => stop(payload)));
|
|
889
|
-
ipcMain.handle('browser:record-start', validated(schemas.browserViewId, (payload) => recordStart(payload)));
|
|
890
|
-
ipcMain.handle('browser:record-stop', validated(schemas.browserViewId, (payload) => recordStop(payload)));
|
|
891
|
-
ipcMain.on('browser:record-event', handleRecordEvent);
|
|
892
|
-
ipcMain.handle('browser:replay', validated(schemas.browserReplay, (payload) => replay(payload)));
|
|
893
|
-
ipcMain.handle('browser:capture-dom', validated(schemas.browserCaptureDom, (payload) => captureDom(payload)));
|
|
894
|
-
ipcMain.handle('browser:capture-shot', validated(schemas.browserViewId, (payload) => captureShot(payload)));
|
|
895
|
-
ipcMain.handle('browser:set-zoom', validated(schemas.browserSetZoom, (payload) => setZoom(payload)));
|
|
896
|
-
ipcMain.handle('browser:find', validated(schemas.browserFind, (payload) => find(payload)));
|
|
897
|
-
ipcMain.handle('browser:stop-find', validated(schemas.browserViewId, (payload) => stopFind(payload)));
|
|
898
|
-
ipcMain.handle('browser:save-binary', validated(schemas.browserSaveBinary, (payload) => {
|
|
899
|
-
const { writeBinaryAtomic } = require('./config.cjs');
|
|
900
|
-
return writeBinaryAtomic(payload.path, Buffer.from(payload.base64, 'base64'), { writer: payload.writer })
|
|
901
|
-
.then(() => ({ ok: true }))
|
|
902
|
-
.catch((e) => ({ ok: false, error: e && e.message ? e.message : String(e) }));
|
|
903
|
-
}));
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
module.exports = {
|
|
907
|
-
registerBrowserView,
|
|
908
|
-
attachWindow,
|
|
909
|
-
views,
|
|
910
|
-
isBrowserViewContents,
|
|
911
|
-
getView,
|
|
912
|
-
listViews,
|
|
913
|
-
dispatchViewAction,
|
|
914
|
-
captureShotForAgent,
|
|
915
|
-
captureAgentDom,
|
|
916
|
-
};
|