claude-code-session-manager 0.35.2 → 0.35.3
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/{TiptapBody-CIEmzy3k.js → TiptapBody-DmCekkUQ.js} +1 -1
- package/dist/assets/index-CHKMzzCM.js +3558 -0
- package/dist/assets/index-DVqmrWP3.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +3 -2
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +3 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +83 -0
- package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +10 -6
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +3 -2
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +12 -7
- package/src/main/__tests__/adminServer.test.cjs +175 -0
- package/src/main/__tests__/runVerify.test.cjs +74 -4
- package/src/main/adminServer.cjs +157 -0
- package/src/main/browserCapture.cjs +714 -0
- package/src/main/browserView.cjs +613 -0
- package/src/main/config.cjs +37 -1
- package/src/main/historyAggregator.cjs +92 -6
- package/src/main/index.cjs +32 -3
- package/src/main/ipcSchemas.cjs +106 -0
- package/src/main/lib/schedulerConfig.cjs +6 -2
- package/src/main/runVerify.cjs +14 -1
- package/src/main/scheduler.cjs +6 -1
- package/src/main/sessionsStore.cjs +4 -3
- package/src/preload/api.d.ts +129 -5
- package/src/preload/browserViewPreload.cjs +67 -0
- package/src/preload/index.cjs +55 -5
- package/dist/assets/index-2Om-ouz6.js +0 -3534
- package/dist/assets/index-DeIJ8SM5.css +0 -32
- package/src/main/docEditor.cjs +0 -92
|
@@ -0,0 +1,613 @@
|
|
|
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, shell } = require('electron');
|
|
22
|
+
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
23
|
+
const { readJson, writeJson } = require('./config.cjs');
|
|
24
|
+
|
|
25
|
+
// ── History + zoom persistence (PRD 402) ──────────────────────────────
|
|
26
|
+
// Both files live under ~/.claude/session-manager/browser/, inside
|
|
27
|
+
// config.cjs's WRITE_PREFIXES allowlist, and are written through
|
|
28
|
+
// config.cjs's writeJson (tmp+rename atomic write) — never re-implemented.
|
|
29
|
+
const BROWSER_DATA_DIR = path.join(os.homedir(), '.claude', 'session-manager', 'browser');
|
|
30
|
+
const HISTORY_PATH = path.join(BROWSER_DATA_DIR, 'history.json');
|
|
31
|
+
const ZOOM_PATH = path.join(BROWSER_DATA_DIR, 'zoom.json');
|
|
32
|
+
const HISTORY_MAX = 500;
|
|
33
|
+
|
|
34
|
+
let lastZoomFactor = 1;
|
|
35
|
+
// Startup-only, fixed constant path (not user input) — a plain sync read is
|
|
36
|
+
// simpler than threading an async load through registerBrowserView's callers.
|
|
37
|
+
function loadPersistedZoom() {
|
|
38
|
+
try {
|
|
39
|
+
const raw = fs.readFileSync(ZOOM_PATH, 'utf8');
|
|
40
|
+
const data = JSON.parse(raw);
|
|
41
|
+
if (data && typeof data.factor === 'number' && Number.isFinite(data.factor)) {
|
|
42
|
+
lastZoomFactor = data.factor;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// no persisted zoom yet — keep default 1
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Serializes read-modify-write cycles against history.json so concurrent
|
|
50
|
+
// navigations across multiple sub-tabs can't race and drop entries.
|
|
51
|
+
let historyWriteChain = Promise.resolve();
|
|
52
|
+
function appendHistoryEntry({ url, title }) {
|
|
53
|
+
if (!url || url === 'about:blank') return;
|
|
54
|
+
historyWriteChain = historyWriteChain.then(async () => {
|
|
55
|
+
try {
|
|
56
|
+
const existing = await readJson(HISTORY_PATH);
|
|
57
|
+
const list = Array.isArray(existing.data) ? existing.data : [];
|
|
58
|
+
list.unshift({ url, title: title || '', ts: Date.now() });
|
|
59
|
+
await writeJson(HISTORY_PATH, list.slice(0, HISTORY_MAX));
|
|
60
|
+
} catch {
|
|
61
|
+
// best-effort — history is a convenience, not load-bearing
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @type {Map<string, WebContentsView>} */
|
|
67
|
+
const views = new Map();
|
|
68
|
+
// webContents.id of every browser-view's webContents — index.cjs's global
|
|
69
|
+
// will-navigate handler consults this set to exempt browser views from the
|
|
70
|
+
// main-window nav lock without weakening the lock itself.
|
|
71
|
+
const browserViewContentsIds = new Set();
|
|
72
|
+
let win = null;
|
|
73
|
+
|
|
74
|
+
// ── Recorder engine (PRD 408) ─────────────────────────────────────────
|
|
75
|
+
// viewId <-> webContents.id so the record-event channel (sent from the
|
|
76
|
+
// page's isolated-preload bridge, which only knows its own sender) can be
|
|
77
|
+
// routed back to the right viewId's step stream.
|
|
78
|
+
const contentsIdToViewId = new Map();
|
|
79
|
+
const recordingViewIds = new Set();
|
|
80
|
+
const stepCounters = new Map();
|
|
81
|
+
// contextBridge.exposeInMainWorld puts the recorder toggle in the EMBEDDED
|
|
82
|
+
// PAGE's own JS context — an adversarial site being recorded could call it
|
|
83
|
+
// directly. The actual step-forwarding gate is `recordingViewIds` (only this
|
|
84
|
+
// module can set it), so a rogue page can't forge/exfiltrate anything, but it
|
|
85
|
+
// could still pause a legitimate session out from under the user. Close that
|
|
86
|
+
// with a per-view secret the page can't read: generated here, handed to the
|
|
87
|
+
// preload via `additionalArguments`/`process.argv` (never touches the DOM or
|
|
88
|
+
// a script tag the page could inspect), and required on every toggle call.
|
|
89
|
+
const recordTokens = new Map();
|
|
90
|
+
|
|
91
|
+
// viewId -> last search text passed to findInPage, so repeat calls with the
|
|
92
|
+
// same text advance to the next/previous match (findNext: true) while a new
|
|
93
|
+
// search term restarts the highlight pass (findNext: false).
|
|
94
|
+
const lastFindText = new Map();
|
|
95
|
+
|
|
96
|
+
function emitRecordStep(viewId, partial) {
|
|
97
|
+
const n = (stepCounters.get(viewId) || 0) + 1;
|
|
98
|
+
stepCounters.set(viewId, n);
|
|
99
|
+
sendIfAlive(win, `browser:record-step:${viewId}`, { n, ...partial });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function attachWindow(mainWindow) {
|
|
103
|
+
win = mainWindow;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isBrowserViewContents(id) {
|
|
107
|
+
return browserViewContentsIds.has(id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeUrl(rawUrl) {
|
|
111
|
+
let url = String(rawUrl || '').trim();
|
|
112
|
+
if (!url) return { ok: false, error: 'empty url' };
|
|
113
|
+
if (/^https?:\/\//i.test(url)) {
|
|
114
|
+
// already has an allowed scheme
|
|
115
|
+
} else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
116
|
+
// has some other scheme with an authority (file://, chrome://, etc.) — reject
|
|
117
|
+
return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
|
|
118
|
+
} else if (/^javascript:/i.test(url) || /^data:/i.test(url)) {
|
|
119
|
+
// schemes with no "//" authority that are still dangerous — reject explicitly
|
|
120
|
+
return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
|
|
121
|
+
} else {
|
|
122
|
+
// bare host[:port]/path — assume https
|
|
123
|
+
url = `https://${url}`;
|
|
124
|
+
}
|
|
125
|
+
if (!/^https:\/\//i.test(url) && !/^http:\/\//i.test(url)) {
|
|
126
|
+
return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
|
|
127
|
+
}
|
|
128
|
+
// Upgrade http:// to https:// per AC (loadURL still allowed to redirect
|
|
129
|
+
// back down if the site truly has no https, but we always request https first).
|
|
130
|
+
if (/^http:\/\//i.test(url)) {
|
|
131
|
+
url = `https://${url.slice('http://'.length)}`;
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, url };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function broadcastNavState(viewId, view) {
|
|
137
|
+
if (!view || view.webContents.isDestroyed()) return;
|
|
138
|
+
const wc = view.webContents;
|
|
139
|
+
const url = wc.getURL();
|
|
140
|
+
sendIfAlive(win, `browser:nav-state:${viewId}`, {
|
|
141
|
+
url,
|
|
142
|
+
title: wc.getTitle(),
|
|
143
|
+
canGoBack: wc.navigationHistory ? wc.navigationHistory.canGoBack() : wc.canGoBack(),
|
|
144
|
+
canGoForward: wc.navigationHistory ? wc.navigationHistory.canGoForward() : wc.canGoForward(),
|
|
145
|
+
loading: wc.isLoading(),
|
|
146
|
+
isSecure: /^https:/i.test(url),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function wireNavEvents(viewId, view) {
|
|
151
|
+
const wc = view.webContents;
|
|
152
|
+
const emit = () => broadcastNavState(viewId, view);
|
|
153
|
+
|
|
154
|
+
wc.on('did-start-navigation', emit);
|
|
155
|
+
wc.on('did-navigate', (_e, url) => {
|
|
156
|
+
emit();
|
|
157
|
+
// wc.getTitle() here still reports the PREVIOUS page's title — the new
|
|
158
|
+
// page's <title> hasn't parsed yet. Wait one tick for dom-ready so the
|
|
159
|
+
// history entry gets the title of the page actually navigated to.
|
|
160
|
+
wc.once('dom-ready', () => appendHistoryEntry({ url, title: wc.getTitle() }));
|
|
161
|
+
if (recordingViewIds.has(viewId)) emitRecordStep(viewId, { verb: 'navigate', target: url });
|
|
162
|
+
});
|
|
163
|
+
wc.on('did-navigate-in-page', emit);
|
|
164
|
+
wc.on('page-title-updated', emit);
|
|
165
|
+
wc.on('did-fail-load', (_e, errorCode) => {
|
|
166
|
+
// -3 is ERR_ABORTED — fires on routine cancelled/superseded loads, not a
|
|
167
|
+
// real failure. Skip so the renderer doesn't show a noisy error banner.
|
|
168
|
+
if (errorCode === -3) return;
|
|
169
|
+
emit();
|
|
170
|
+
});
|
|
171
|
+
// The recorder-preload's `capturing` flag lives in the page's JS context,
|
|
172
|
+
// which is torn down on every navigation — re-arm it after each load so a
|
|
173
|
+
// recording session survives the page it started on navigating away.
|
|
174
|
+
wc.on('did-finish-load', () => {
|
|
175
|
+
if (!recordingViewIds.has(viewId)) return;
|
|
176
|
+
const token = recordTokens.get(viewId);
|
|
177
|
+
wc.executeJavaScript(
|
|
178
|
+
`window.__smRecorder && window.__smRecorder.setRecording(true, ${JSON.stringify(token)})`,
|
|
179
|
+
).catch(() => {});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
wc.setWindowOpenHandler(({ url }) => {
|
|
183
|
+
if (/^https?:\/\//i.test(url)) {
|
|
184
|
+
shell.openExternal(url).catch(() => {});
|
|
185
|
+
}
|
|
186
|
+
return { action: 'deny' };
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
wc.session.on('will-download', (event) => {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
wc.on('found-in-page', (_e, result) => {
|
|
194
|
+
sendIfAlive(win, `browser:find-result:${viewId}`, {
|
|
195
|
+
requestId: result.requestId,
|
|
196
|
+
matches: result.matches,
|
|
197
|
+
activeMatchOrdinal: result.activeMatchOrdinal,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function create({ viewId, partition }) {
|
|
203
|
+
if (views.has(viewId)) return { ok: true };
|
|
204
|
+
const recordToken = crypto.randomBytes(16).toString('hex');
|
|
205
|
+
recordTokens.set(viewId, recordToken);
|
|
206
|
+
const view = new WebContentsView({
|
|
207
|
+
webPreferences: {
|
|
208
|
+
contextIsolation: true,
|
|
209
|
+
nodeIntegration: false,
|
|
210
|
+
sandbox: true,
|
|
211
|
+
partition,
|
|
212
|
+
preload: path.join(__dirname, '..', 'preload', 'browserViewPreload.cjs'),
|
|
213
|
+
additionalArguments: [`--sm-record-token=${recordToken}`],
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
views.set(viewId, view);
|
|
217
|
+
browserViewContentsIds.add(view.webContents.id);
|
|
218
|
+
contentsIdToViewId.set(view.webContents.id, viewId);
|
|
219
|
+
view.webContents.setZoomFactor(lastZoomFactor);
|
|
220
|
+
wireNavEvents(viewId, view);
|
|
221
|
+
view.webContents.once('destroyed', () => {
|
|
222
|
+
browserViewContentsIds.delete(view.webContents.id);
|
|
223
|
+
contentsIdToViewId.delete(view.webContents.id);
|
|
224
|
+
recordingViewIds.delete(viewId);
|
|
225
|
+
stepCounters.delete(viewId);
|
|
226
|
+
recordTokens.delete(viewId);
|
|
227
|
+
lastFindText.delete(viewId);
|
|
228
|
+
});
|
|
229
|
+
if (win && !win.isDestroyed()) {
|
|
230
|
+
win.contentView.addChildView(view);
|
|
231
|
+
}
|
|
232
|
+
return { ok: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function setBounds({ viewId, x, y, width, height }) {
|
|
236
|
+
const view = views.get(viewId);
|
|
237
|
+
if (!view) return { ok: false };
|
|
238
|
+
view.setBounds({
|
|
239
|
+
x: Math.round(x),
|
|
240
|
+
y: Math.round(y),
|
|
241
|
+
width: Math.round(width),
|
|
242
|
+
height: Math.round(height),
|
|
243
|
+
});
|
|
244
|
+
return { ok: true };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function show({ viewId }) {
|
|
248
|
+
const view = views.get(viewId);
|
|
249
|
+
if (!view) return { ok: false };
|
|
250
|
+
view.setVisible(true);
|
|
251
|
+
return { ok: true };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function hide({ viewId }) {
|
|
255
|
+
const view = views.get(viewId);
|
|
256
|
+
if (!view) return { ok: false };
|
|
257
|
+
view.setVisible(false);
|
|
258
|
+
return { ok: true };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function destroy({ viewId }) {
|
|
262
|
+
const view = views.get(viewId);
|
|
263
|
+
if (!view) return { ok: true };
|
|
264
|
+
browserViewContentsIds.delete(view.webContents.id);
|
|
265
|
+
contentsIdToViewId.delete(view.webContents.id);
|
|
266
|
+
recordingViewIds.delete(viewId);
|
|
267
|
+
stepCounters.delete(viewId);
|
|
268
|
+
lastFindText.delete(viewId);
|
|
269
|
+
if (win && !win.isDestroyed()) {
|
|
270
|
+
try { win.contentView.removeChildView(view); } catch { /* already detached */ }
|
|
271
|
+
}
|
|
272
|
+
try { view.webContents.close(); } catch { /* already closed */ }
|
|
273
|
+
views.delete(viewId);
|
|
274
|
+
return { ok: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// PRD 402: address-bar zoom control. Factor is clamped to Chromium's own
|
|
278
|
+
// findInPage-adjacent zoom range and persisted so the next-opened tab (and
|
|
279
|
+
// next app launch, via loadPersistedZoom) starts at the last value used.
|
|
280
|
+
function setZoom({ viewId, factor }) {
|
|
281
|
+
const view = views.get(viewId);
|
|
282
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
283
|
+
const clamped = Math.min(3, Math.max(0.25, Number(factor) || 1));
|
|
284
|
+
view.webContents.setZoomFactor(clamped);
|
|
285
|
+
lastZoomFactor = clamped;
|
|
286
|
+
writeJson(ZOOM_PATH, { factor: clamped }).catch(() => {});
|
|
287
|
+
return { ok: true, factor: clamped };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// PRD 402: Cmd/Ctrl+F find bar. Empty text clears the search instead of
|
|
291
|
+
// running an empty findInPage query (Electron throws on '').
|
|
292
|
+
function find({ viewId, text, forward }) {
|
|
293
|
+
const view = views.get(viewId);
|
|
294
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
295
|
+
if (!text) {
|
|
296
|
+
view.webContents.stopFindInPage('clearSelection');
|
|
297
|
+
lastFindText.delete(viewId);
|
|
298
|
+
return { ok: true };
|
|
299
|
+
}
|
|
300
|
+
const isRepeat = lastFindText.get(viewId) === text;
|
|
301
|
+
lastFindText.set(viewId, text);
|
|
302
|
+
view.webContents.findInPage(text, { forward: forward !== false, findNext: isRepeat });
|
|
303
|
+
return { ok: true };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function stopFind({ viewId }) {
|
|
307
|
+
const view = views.get(viewId);
|
|
308
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
309
|
+
view.webContents.stopFindInPage('clearSelection');
|
|
310
|
+
lastFindText.delete(viewId);
|
|
311
|
+
return { ok: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function recordStart({ viewId }) {
|
|
315
|
+
const view = views.get(viewId);
|
|
316
|
+
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
317
|
+
recordingViewIds.add(viewId);
|
|
318
|
+
stepCounters.set(viewId, 0);
|
|
319
|
+
const token = recordTokens.get(viewId);
|
|
320
|
+
view.webContents
|
|
321
|
+
.executeJavaScript(`window.__smRecorder && window.__smRecorder.setRecording(true, ${JSON.stringify(token)})`)
|
|
322
|
+
.catch(() => {});
|
|
323
|
+
return { ok: true };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function recordStop({ viewId }) {
|
|
327
|
+
recordingViewIds.delete(viewId);
|
|
328
|
+
const view = views.get(viewId);
|
|
329
|
+
if (view && !view.webContents.isDestroyed()) {
|
|
330
|
+
const token = recordTokens.get(viewId);
|
|
331
|
+
view.webContents
|
|
332
|
+
.executeJavaScript(`window.__smRecorder && window.__smRecorder.setRecording(false, ${JSON.stringify(token)})`)
|
|
333
|
+
.catch(() => {});
|
|
334
|
+
}
|
|
335
|
+
return { ok: true };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Sent by the recorder-preload's contextBridge (never the page itself — the
|
|
339
|
+
// page has no node/ipcRenderer access under sandbox+contextIsolation). Only
|
|
340
|
+
// forwarded while the view is actually in a recording session, and only the
|
|
341
|
+
// two verbs the preload emits; typed values are never included (privacy
|
|
342
|
+
// invariant — "sandboxed · no filesystem · no passwords").
|
|
343
|
+
function handleRecordEvent(event, payload) {
|
|
344
|
+
const viewId = contentsIdToViewId.get(event.sender.id);
|
|
345
|
+
if (!viewId || !recordingViewIds.has(viewId)) return;
|
|
346
|
+
const verb = payload && payload.verb;
|
|
347
|
+
if (verb !== 'click' && verb !== 'type') return;
|
|
348
|
+
const target = typeof payload.target === 'string' ? payload.target.slice(0, 300) : '';
|
|
349
|
+
const step = { verb, target };
|
|
350
|
+
if (verb === 'type') {
|
|
351
|
+
step.masked = true;
|
|
352
|
+
if (typeof payload.variableSuggestion === 'string') {
|
|
353
|
+
step.variableSuggestion = payload.variableSuggestion.slice(0, 64);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
emitRecordStep(viewId, step);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Replay engine (PRD 410) ───────────────────────────────────────────
|
|
360
|
+
// Each step is bounded so a bad/stale selector can't hang the run; the
|
|
361
|
+
// caller supplies `steps` (renderer owns the recorded step list — the main
|
|
362
|
+
// process never persists them), so replay is stateless between calls.
|
|
363
|
+
const REPLAY_STEP_TIMEOUT_MS = 10_000;
|
|
364
|
+
const REPLAY_WAIT_POLL_MS = 250;
|
|
365
|
+
|
|
366
|
+
function withTimeout(promise, ms, label) {
|
|
367
|
+
let timer;
|
|
368
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
369
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
370
|
+
});
|
|
371
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function replayClickOrType(wc, step, values) {
|
|
375
|
+
const script =
|
|
376
|
+
step.verb === 'type'
|
|
377
|
+
? `(() => {
|
|
378
|
+
const el = document.querySelector(${JSON.stringify(step.target)});
|
|
379
|
+
if (!el) return false;
|
|
380
|
+
el.focus();
|
|
381
|
+
// React (and similar frameworks) override the native value setter
|
|
382
|
+
// to track state; assigning el.value directly leaves that tracked
|
|
383
|
+
// state stale, so the framework's onChange never fires. Go through
|
|
384
|
+
// the native prototype setter — the same workaround React Testing
|
|
385
|
+
// Library / Playwright use — so the dispatched 'input' event is
|
|
386
|
+
// seen as a real change.
|
|
387
|
+
const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
|
388
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
389
|
+
if (nativeSetter && nativeSetter.set) {
|
|
390
|
+
nativeSetter.set.call(el, ${JSON.stringify(step.variable && values[step.variable] !== undefined ? String(values[step.variable]) : '')});
|
|
391
|
+
} else {
|
|
392
|
+
el.value = ${JSON.stringify(step.variable && values[step.variable] !== undefined ? String(values[step.variable]) : '')};
|
|
393
|
+
}
|
|
394
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
395
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
396
|
+
return true;
|
|
397
|
+
})()`
|
|
398
|
+
: step.verb === 'select'
|
|
399
|
+
? `(() => {
|
|
400
|
+
const el = document.querySelector(${JSON.stringify(step.target)});
|
|
401
|
+
if (!el) return false;
|
|
402
|
+
el.value = ${JSON.stringify(step.value ?? '')};
|
|
403
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
404
|
+
return true;
|
|
405
|
+
})()`
|
|
406
|
+
: `(() => {
|
|
407
|
+
const el = document.querySelector(${JSON.stringify(step.target)});
|
|
408
|
+
if (!el) return false;
|
|
409
|
+
el.click();
|
|
410
|
+
return true;
|
|
411
|
+
})()`;
|
|
412
|
+
const found = await withTimeout(wc.executeJavaScript(script), REPLAY_STEP_TIMEOUT_MS, step.verb);
|
|
413
|
+
if (!found) throw new Error(`selector not found: ${step.target}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function replayWaitFor(wc, step) {
|
|
417
|
+
const target = step.target;
|
|
418
|
+
const deadline = Date.now() + REPLAY_STEP_TIMEOUT_MS;
|
|
419
|
+
const checkScript = `(() => {
|
|
420
|
+
try {
|
|
421
|
+
return (document.body && document.body.innerText.includes(${JSON.stringify(target)})) || location.href.includes(${JSON.stringify(target)});
|
|
422
|
+
} catch { return false; }
|
|
423
|
+
})()`;
|
|
424
|
+
for (;;) {
|
|
425
|
+
const found = await wc.executeJavaScript(checkScript).catch(() => false);
|
|
426
|
+
if (found) return;
|
|
427
|
+
if (Date.now() >= deadline) throw new Error(`wait-for timed out: ${target}`);
|
|
428
|
+
await new Promise((r) => setTimeout(r, REPLAY_WAIT_POLL_MS));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function replayStep(view, step, values) {
|
|
433
|
+
const wc = view.webContents;
|
|
434
|
+
if (step.verb === 'navigate') {
|
|
435
|
+
const normalized = normalizeUrl(step.target);
|
|
436
|
+
if (!normalized.ok) throw new Error(normalized.error);
|
|
437
|
+
await withTimeout(wc.loadURL(normalized.url), REPLAY_STEP_TIMEOUT_MS, 'navigate');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (step.verb === 'click' || step.verb === 'type' || step.verb === 'select') {
|
|
441
|
+
await replayClickOrType(wc, step, values);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (step.verb === 'wait-for') {
|
|
445
|
+
await replayWaitFor(wc, step);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
throw new Error(`unsupported step verb: ${step.verb}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// `continueOnError` defaults to false: replay stops at the first failed step
|
|
452
|
+
// (fail-fast, matches the transport's "pass/fail per step" model) — the
|
|
453
|
+
// caller can opt into running every step regardless by passing `true`.
|
|
454
|
+
//
|
|
455
|
+
// Replayed navigations/clicks/types are synthetic but indistinguishable from
|
|
456
|
+
// real user input to the recorder's own listeners (did-navigate,
|
|
457
|
+
// browser:record-event) — without suppressing capture here, replaying a
|
|
458
|
+
// session that's still "recording" (armed until stop, not just paused; see
|
|
459
|
+
// browser.ts toggleRecordingPause) would re-append every replayed step back
|
|
460
|
+
// into the step list. Drop the viewId out of recordingViewIds for the
|
|
461
|
+
// duration of the run and restore its prior membership afterward.
|
|
462
|
+
async function replay({ viewId, steps, values, continueOnError }) {
|
|
463
|
+
const view = views.get(viewId);
|
|
464
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
465
|
+
const stepList = Array.isArray(steps) ? steps : [];
|
|
466
|
+
const resolvedValues = values || {};
|
|
467
|
+
const wasRecording = recordingViewIds.has(viewId);
|
|
468
|
+
recordingViewIds.delete(viewId);
|
|
469
|
+
try {
|
|
470
|
+
for (const step of stepList) {
|
|
471
|
+
try {
|
|
472
|
+
await replayStep(view, step, resolvedValues);
|
|
473
|
+
sendIfAlive(win, `browser:replay-step:${viewId}`, { n: step.n, status: 'pass' });
|
|
474
|
+
} catch (e) {
|
|
475
|
+
const detail = e && e.message ? e.message : String(e);
|
|
476
|
+
sendIfAlive(win, `browser:replay-step:${viewId}`, { n: step.n, status: 'fail', detail });
|
|
477
|
+
if (!continueOnError) return { ok: true, stopped: true, failedAt: step.n };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { ok: true, stopped: false };
|
|
481
|
+
} finally {
|
|
482
|
+
if (wasRecording) recordingViewIds.add(viewId);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function navigate({ viewId, url }) {
|
|
487
|
+
const view = views.get(viewId);
|
|
488
|
+
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
489
|
+
const normalized = normalizeUrl(url);
|
|
490
|
+
if (!normalized.ok) return { ok: false, error: normalized.error };
|
|
491
|
+
view.webContents.loadURL(normalized.url).catch(() => {
|
|
492
|
+
// Load failures surface via did-fail-load → broadcastNavState; nothing
|
|
493
|
+
// further to do here.
|
|
494
|
+
});
|
|
495
|
+
return { ok: true };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function back({ viewId }) {
|
|
499
|
+
const view = views.get(viewId);
|
|
500
|
+
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
501
|
+
const wc = view.webContents;
|
|
502
|
+
const nav = wc.navigationHistory;
|
|
503
|
+
const canGoBack = nav ? nav.canGoBack() : wc.canGoBack();
|
|
504
|
+
if (!canGoBack) return { ok: false, error: 'cannot go back' };
|
|
505
|
+
if (nav) nav.goBack(); else wc.goBack();
|
|
506
|
+
return { ok: true };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function forward({ viewId }) {
|
|
510
|
+
const view = views.get(viewId);
|
|
511
|
+
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
512
|
+
const wc = view.webContents;
|
|
513
|
+
const nav = wc.navigationHistory;
|
|
514
|
+
const canGoForward = nav ? nav.canGoForward() : wc.canGoForward();
|
|
515
|
+
if (!canGoForward) return { ok: false, error: 'cannot go forward' };
|
|
516
|
+
if (nav) nav.goForward(); else wc.goForward();
|
|
517
|
+
return { ok: true };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function reload({ viewId }) {
|
|
521
|
+
const view = views.get(viewId);
|
|
522
|
+
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
523
|
+
view.webContents.reload();
|
|
524
|
+
return { ok: true };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function stop({ viewId }) {
|
|
528
|
+
const view = views.get(viewId);
|
|
529
|
+
if (!view) return { ok: false, error: 'unknown viewId' };
|
|
530
|
+
view.webContents.stop();
|
|
531
|
+
return { ok: true };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Cap returned capture text so a huge DOM can't blow up the IPC channel.
|
|
535
|
+
const CAPTURE_TEXT_MAX = 500_000;
|
|
536
|
+
|
|
537
|
+
// PRD 407: grab page text/HTML for the Capture panel. Reuses the same
|
|
538
|
+
// executeJavaScript access path the recorder-preload re-arm already uses
|
|
539
|
+
// above (did-finish-load handler) — one round trip returns url/title/text
|
|
540
|
+
// together instead of three separate calls.
|
|
541
|
+
async function captureDom({ viewId, kind }) {
|
|
542
|
+
const view = views.get(viewId);
|
|
543
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
544
|
+
try {
|
|
545
|
+
const script = kind === 'html'
|
|
546
|
+
? '({ url: location.href, title: document.title, text: document.documentElement.outerHTML })'
|
|
547
|
+
: '({ url: location.href, title: document.title, text: document.body ? document.body.innerText : "" })';
|
|
548
|
+
const result = await view.webContents.executeJavaScript(script);
|
|
549
|
+
let text = typeof result?.text === 'string' ? result.text : '';
|
|
550
|
+
let truncated = false;
|
|
551
|
+
if (text.length > CAPTURE_TEXT_MAX) {
|
|
552
|
+
text = text.slice(0, CAPTURE_TEXT_MAX);
|
|
553
|
+
truncated = true;
|
|
554
|
+
}
|
|
555
|
+
return { ok: true, url: result?.url || '', title: result?.title || '', text, truncated };
|
|
556
|
+
} catch (e) {
|
|
557
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// PRD 407: screenshot the active browser sub-tab as a PNG data URL.
|
|
562
|
+
async function captureShot({ viewId }) {
|
|
563
|
+
const view = views.get(viewId);
|
|
564
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
565
|
+
try {
|
|
566
|
+
const wc = view.webContents;
|
|
567
|
+
const image = await wc.capturePage();
|
|
568
|
+
const dataUrl = image.toDataURL();
|
|
569
|
+
return { ok: true, url: wc.getURL(), title: wc.getTitle(), dataUrl };
|
|
570
|
+
} catch (e) {
|
|
571
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// PRD 403: getter passed to browserCapture.cjs's registerBrowserCapture so
|
|
576
|
+
// the picker can reach a live WebContentsView without this module exposing
|
|
577
|
+
// its whole `views` Map as a mutable dependency surface.
|
|
578
|
+
function getView(viewId) {
|
|
579
|
+
return views.get(viewId);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function registerBrowserView({ mainWindow, ipcMain }) {
|
|
583
|
+
attachWindow(mainWindow);
|
|
584
|
+
loadPersistedZoom();
|
|
585
|
+
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
586
|
+
ipcMain.handle('browser:create', validated(schemas.browserCreate, (payload) => create(payload)));
|
|
587
|
+
ipcMain.handle('browser:set-bounds', validated(schemas.browserSetBounds, (payload) => setBounds(payload)));
|
|
588
|
+
ipcMain.handle('browser:show', validated(schemas.browserViewId, (payload) => show(payload)));
|
|
589
|
+
ipcMain.handle('browser:hide', validated(schemas.browserViewId, (payload) => hide(payload)));
|
|
590
|
+
ipcMain.handle('browser:destroy', validated(schemas.browserViewId, (payload) => destroy(payload)));
|
|
591
|
+
ipcMain.handle('browser:navigate', validated(schemas.browserNavigate, (payload) => navigate(payload)));
|
|
592
|
+
ipcMain.handle('browser:back', validated(schemas.browserViewId, (payload) => back(payload)));
|
|
593
|
+
ipcMain.handle('browser:forward', validated(schemas.browserViewId, (payload) => forward(payload)));
|
|
594
|
+
ipcMain.handle('browser:reload', validated(schemas.browserViewId, (payload) => reload(payload)));
|
|
595
|
+
ipcMain.handle('browser:stop', validated(schemas.browserViewId, (payload) => stop(payload)));
|
|
596
|
+
ipcMain.handle('browser:record-start', validated(schemas.browserViewId, (payload) => recordStart(payload)));
|
|
597
|
+
ipcMain.handle('browser:record-stop', validated(schemas.browserViewId, (payload) => recordStop(payload)));
|
|
598
|
+
ipcMain.on('browser:record-event', handleRecordEvent);
|
|
599
|
+
ipcMain.handle('browser:replay', validated(schemas.browserReplay, (payload) => replay(payload)));
|
|
600
|
+
ipcMain.handle('browser:capture-dom', validated(schemas.browserCaptureDom, (payload) => captureDom(payload)));
|
|
601
|
+
ipcMain.handle('browser:capture-shot', validated(schemas.browserViewId, (payload) => captureShot(payload)));
|
|
602
|
+
ipcMain.handle('browser:set-zoom', validated(schemas.browserSetZoom, (payload) => setZoom(payload)));
|
|
603
|
+
ipcMain.handle('browser:find', validated(schemas.browserFind, (payload) => find(payload)));
|
|
604
|
+
ipcMain.handle('browser:stop-find', validated(schemas.browserViewId, (payload) => stopFind(payload)));
|
|
605
|
+
ipcMain.handle('browser:save-binary', validated(schemas.browserSaveBinary, (payload) => {
|
|
606
|
+
const { writeBinaryAtomic } = require('./config.cjs');
|
|
607
|
+
return writeBinaryAtomic(payload.path, Buffer.from(payload.base64, 'base64'))
|
|
608
|
+
.then(() => ({ ok: true }))
|
|
609
|
+
.catch((e) => ({ ok: false, error: e && e.message ? e.message : String(e) }));
|
|
610
|
+
}));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
module.exports = { registerBrowserView, attachWindow, views, isBrowserViewContents, getView };
|
package/src/main/config.cjs
CHANGED
|
@@ -103,7 +103,10 @@ function validateWrite(realAbs) {
|
|
|
103
103
|
(p) => realAbs === p || realAbs.startsWith(p + path.sep)
|
|
104
104
|
);
|
|
105
105
|
if (inWritePrefix) return;
|
|
106
|
-
// Also allowed inside a registered project root's .claude/ subtree
|
|
106
|
+
// Also allowed inside a registered project root's .claude/ subtree, OR its
|
|
107
|
+
// tests/fixtures/browser-capture/ subtree (PRD 407 "PRD fixture" capture
|
|
108
|
+
// destination — narrowly scoped to that one path segment, not a general
|
|
109
|
+
// project-root write grant).
|
|
107
110
|
for (const root of allowedRoots) {
|
|
108
111
|
if (root === os.homedir()) continue;
|
|
109
112
|
let realRoot;
|
|
@@ -113,6 +116,17 @@ function validateWrite(realAbs) {
|
|
|
113
116
|
if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
|
|
114
117
|
return;
|
|
115
118
|
}
|
|
119
|
+
const fixturesSub = path.join(realRoot, 'tests', 'fixtures', 'browser-capture');
|
|
120
|
+
if (realAbs === fixturesSub || realAbs.startsWith(fixturesSub + path.sep)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// PRD 410 Recorder → Playwright-spec export: narrowly scoped to
|
|
124
|
+
// tests/e2e/ (matches the repo's existing e2e spec location), not a
|
|
125
|
+
// general project-root write grant.
|
|
126
|
+
const e2eSub = path.join(realRoot, 'tests', 'e2e');
|
|
127
|
+
if (realAbs === e2eSub || realAbs.startsWith(e2eSub + path.sep)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
132
|
throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
|
|
@@ -182,6 +196,27 @@ async function writeTextAtomic(abs, text, opts = {}) {
|
|
|
182
196
|
return { ok: true, mtimeMs: stat.mtimeMs };
|
|
183
197
|
}
|
|
184
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Binary-safe sibling of writeTextAtomic — same validate + tmp+rename flow,
|
|
201
|
+
* for callers (screenshot capture) whose payload is a Buffer, not utf8 text.
|
|
202
|
+
*/
|
|
203
|
+
async function writeBinaryAtomic(abs, buffer) {
|
|
204
|
+
const real = validatePath(expandHome(abs));
|
|
205
|
+
validateWrite(real);
|
|
206
|
+
const dir = path.dirname(real);
|
|
207
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
208
|
+
const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
209
|
+
try {
|
|
210
|
+
await fsp.writeFile(tmp, buffer);
|
|
211
|
+
await fsp.rename(tmp, real);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
try { await fsp.unlink(tmp); } catch { /* tmp never created or already gone */ }
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
const stat = await fsp.stat(real);
|
|
217
|
+
return { ok: true, mtimeMs: stat.mtimeMs };
|
|
218
|
+
}
|
|
219
|
+
|
|
185
220
|
async function writeJson(abs, data) {
|
|
186
221
|
const pretty = JSON.stringify(data, null, 2) + '\n';
|
|
187
222
|
return writeTextAtomic(abs, pretty);
|
|
@@ -374,6 +409,7 @@ module.exports = {
|
|
|
374
409
|
writeJson,
|
|
375
410
|
writeJsonSync,
|
|
376
411
|
writeTextAtomic,
|
|
412
|
+
writeBinaryAtomic,
|
|
377
413
|
listDir,
|
|
378
414
|
exists,
|
|
379
415
|
addAllowedRoot,
|