dsh-browser-playwright-codex 0.7.2 → 0.7.5
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/CHANGELOG.md +74 -0
- package/README.md +17 -5
- package/SECURITY.md +5 -1
- package/lib/browser-lifecycle.d.ts +13 -1
- package/lib/browser-lifecycle.js +57 -6
- package/lib/cache-clean.d.ts +90 -0
- package/lib/cache-clean.js +386 -0
- package/lib/challenge.d.ts +8 -0
- package/lib/challenge.js +16 -0
- package/lib/client.js +165 -0
- package/lib/errors.d.ts +12 -0
- package/lib/errors.js +16 -0
- package/lib/index.d.ts +4 -2
- package/lib/index.js +3 -2
- package/lib/navigation-guard.d.ts +48 -0
- package/lib/navigation-guard.js +48 -0
- package/lib/page-actions.js +6 -0
- package/lib/page-snapshot.js +5 -0
- package/lib/playwright.d.ts +1 -1
- package/lib/playwright.js +5 -2
- package/lib/provider.d.ts +56 -0
- package/lib/provider.js +117 -5
- package/lib/runtime-state.d.ts +20 -0
- package/lib/runtime-state.js +53 -0
- package/lib/session.d.ts +33 -3
- package/lib/session.js +86 -5
- package/lib/settings-card.js +129 -0
- package/lib/tool-render.js +3 -2
- package/lib/tool.js +13 -8
- package/lib/url-policy.d.ts +13 -0
- package/lib/url-policy.js +24 -2
- package/package.json +1 -1
- package/scripts/dsh-browser-verify.mjs +34 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache-residue scanning and cleaning for the Settings card.
|
|
3
|
+
*
|
|
4
|
+
* Design rules (these are the safety boundary, not implementation details):
|
|
5
|
+
* - **Targets are enum ids, never paths.** The caller can only ask for
|
|
6
|
+
* `browser-cache` / `temp-residue` / `updater-residue` / `attachment-cache`;
|
|
7
|
+
* every path below is derived here from DSH_HOME and known OS locations, so a
|
|
8
|
+
* request can never point deletion at an arbitrary directory.
|
|
9
|
+
* - **Whitelist only.** Inside the browser profile only Chromium's pure caches
|
|
10
|
+
* are touched; Cookies, Local Storage, Service Worker, IndexedDB, Network,
|
|
11
|
+
* Preferences and Login Data are never opened.
|
|
12
|
+
* - **Rename before delete.** A directory that is in use fails the rename on
|
|
13
|
+
* Windows; that is treated as "in use" and reported instead of retried.
|
|
14
|
+
* - **Count after success.** Bytes are measured before removal but only added to
|
|
15
|
+
* `freedBytes` once the entry is confirmed gone — the earlier disk-cleanup rule
|
|
16
|
+
* "count += size before deleting and swallow errors" produced fake accounting.
|
|
17
|
+
* - Pure `node:fs` — no dependencies, so it is testable without a DSH host.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-browser-playwright-codex/cache-clean
|
|
20
|
+
*/
|
|
21
|
+
import { chmod, lstat, readdir, rename, rm } from 'node:fs/promises';
|
|
22
|
+
import { homedir, tmpdir } from 'node:os';
|
|
23
|
+
import { basename, join } from 'node:path';
|
|
24
|
+
/** Display order and labels for every target. */
|
|
25
|
+
export const TARGETS = [
|
|
26
|
+
{ id: 'browser-cache', label: '浏览器缓存' },
|
|
27
|
+
{ id: 'temp-residue', label: '临时残留' },
|
|
28
|
+
{ id: 'updater-residue', label: '更新器残留' },
|
|
29
|
+
{ id: 'attachment-cache', label: '图片变体缓存' },
|
|
30
|
+
];
|
|
31
|
+
/** Is this value one of the enum ids? Unknown strings are rejected, never joined into a path. */
|
|
32
|
+
export function isTargetId(value) {
|
|
33
|
+
return typeof value === 'string' && TARGETS.some((target) => target.id === value);
|
|
34
|
+
}
|
|
35
|
+
/** Resolve the default paths from the environment (no path ever comes from the request). */
|
|
36
|
+
export function resolvePaths(env = process.env, version, platform = process.platform) {
|
|
37
|
+
const home = env.DSH_HOME !== undefined && env.DSH_HOME !== '' ? env.DSH_HOME : join(homedir(), '.dsh');
|
|
38
|
+
const profileOverride = env.DSH_BROWSER_PROFILE_DIR;
|
|
39
|
+
const localAppData = env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local');
|
|
40
|
+
return {
|
|
41
|
+
dshHome: home,
|
|
42
|
+
browserProfileDir: profileOverride !== undefined && profileOverride !== ''
|
|
43
|
+
? profileOverride
|
|
44
|
+
: join(home, 'browser-profiles', 'playwright'),
|
|
45
|
+
tmpDir: tmpdir(),
|
|
46
|
+
updaterDir: join(localAppData, '@deepseek-aidsh-desktop-updater'),
|
|
47
|
+
installedVersion: version,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Chromium's pure caches. Everything else in the profile is off limits. */
|
|
51
|
+
const BROWSER_CACHE_DIRS = [
|
|
52
|
+
'Cache',
|
|
53
|
+
'Code Cache',
|
|
54
|
+
'GPUCache',
|
|
55
|
+
'ShaderCache',
|
|
56
|
+
'GrShaderCache',
|
|
57
|
+
'DawnCache',
|
|
58
|
+
'DawnGraphiteCache',
|
|
59
|
+
'ScriptCache',
|
|
60
|
+
];
|
|
61
|
+
/** Temp entries this plugin and the harness leave behind. */
|
|
62
|
+
const TEMP_PREFIXES = [
|
|
63
|
+
'dsh-browser-state-',
|
|
64
|
+
'dsh-provider-state-',
|
|
65
|
+
'dsh-subprocess-',
|
|
66
|
+
'dsh-spill-',
|
|
67
|
+
'dsh-acl-skill-',
|
|
68
|
+
'dsh-login-state-',
|
|
69
|
+
];
|
|
70
|
+
/** A temp entry younger than this is assumed to belong to a running task. */
|
|
71
|
+
const TEMP_MIN_AGE_MS = 30 * 60 * 1000;
|
|
72
|
+
/** Measure a file or directory tree. Missing paths measure as zero. */
|
|
73
|
+
async function measure(path) {
|
|
74
|
+
const st = await lstat(path).catch(() => undefined);
|
|
75
|
+
if (st === undefined)
|
|
76
|
+
return { bytes: 0, files: 0 };
|
|
77
|
+
if (!st.isDirectory())
|
|
78
|
+
return { bytes: st.size, files: 1 };
|
|
79
|
+
let bytes = 0;
|
|
80
|
+
let files = 0;
|
|
81
|
+
const stack = [path];
|
|
82
|
+
while (stack.length > 0) {
|
|
83
|
+
const dir = stack.pop();
|
|
84
|
+
if (dir === undefined)
|
|
85
|
+
break;
|
|
86
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => undefined);
|
|
87
|
+
if (entries === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const child = join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
stack.push(child);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const size = await lstat(child).catch(() => undefined);
|
|
96
|
+
if (size !== undefined) {
|
|
97
|
+
bytes += size.size;
|
|
98
|
+
files += 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { bytes, files };
|
|
103
|
+
}
|
|
104
|
+
/** Windows refuses to delete read-only files even with `force`; clear the attribute first. */
|
|
105
|
+
async function makeWritable(path) {
|
|
106
|
+
const st = await lstat(path).catch(() => undefined);
|
|
107
|
+
if (st === undefined)
|
|
108
|
+
return;
|
|
109
|
+
if (st.isDirectory()) {
|
|
110
|
+
const entries = await readdir(path).catch(() => []);
|
|
111
|
+
for (const name of entries)
|
|
112
|
+
await makeWritable(join(path, name));
|
|
113
|
+
await chmod(path, 0o777).catch(() => undefined);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await chmod(path, 0o666).catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
/** Remove an entry, retrying once after clearing read-only attributes. */
|
|
119
|
+
async function removeEntry(path) {
|
|
120
|
+
try {
|
|
121
|
+
await rm(path, { recursive: true, force: true, maxRetries: 2 });
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
await makeWritable(path);
|
|
125
|
+
await rm(path, { recursive: true, force: true, maxRetries: 2 }).catch(() => undefined);
|
|
126
|
+
}
|
|
127
|
+
return (await lstat(path).catch(() => undefined)) === undefined;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Detach an entry by renaming it first, then delete the renamed copy.
|
|
131
|
+
* A rename failure means something holds it open (Windows locks the directory),
|
|
132
|
+
* which is exactly the "in use" signal we want instead of guessing.
|
|
133
|
+
*/
|
|
134
|
+
async function detachAndRemove(path) {
|
|
135
|
+
const detached = `${path}.dsh-delete-${process.pid}-${Date.now()}`;
|
|
136
|
+
try {
|
|
137
|
+
await rename(path, detached);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return { ok: false, reason: 'in-use' };
|
|
141
|
+
}
|
|
142
|
+
const removed = await removeEntry(detached);
|
|
143
|
+
return removed ? { ok: true } : { ok: false, reason: 'permission' };
|
|
144
|
+
}
|
|
145
|
+
/** Browser-profile cache directories that currently exist. */
|
|
146
|
+
async function browserCacheDirs(profileDir) {
|
|
147
|
+
const defaultDir = join(profileDir, 'Default');
|
|
148
|
+
const entries = await readdir(defaultDir, { withFileTypes: true }).catch(() => undefined);
|
|
149
|
+
if (entries === undefined)
|
|
150
|
+
return [];
|
|
151
|
+
return entries
|
|
152
|
+
.filter((entry) => entry.isDirectory())
|
|
153
|
+
.map((entry) => entry.name)
|
|
154
|
+
.filter((name) => BROWSER_CACHE_DIRS.includes(name) || /_crx_cache$/.test(name))
|
|
155
|
+
.map((name) => join(defaultDir, name));
|
|
156
|
+
}
|
|
157
|
+
/** Temp entries matching the known residue prefixes. */
|
|
158
|
+
async function tempResidueDirs(tmpDir) {
|
|
159
|
+
const entries = await readdir(tmpDir, { withFileTypes: true }).catch(() => undefined);
|
|
160
|
+
if (entries === undefined)
|
|
161
|
+
return [];
|
|
162
|
+
return entries
|
|
163
|
+
.filter((entry) => entry.isDirectory())
|
|
164
|
+
.filter((entry) => TEMP_PREFIXES.some((prefix) => entry.name.startsWith(prefix)))
|
|
165
|
+
.map((entry) => join(tmpDir, entry.name));
|
|
166
|
+
}
|
|
167
|
+
/** Updater files that belong to the currently installed version. */
|
|
168
|
+
async function updaterResidue(updaterDir, installedVersion) {
|
|
169
|
+
const files = [];
|
|
170
|
+
const skipped = [];
|
|
171
|
+
const entries = await readdir(updaterDir, { withFileTypes: true }).catch(() => undefined);
|
|
172
|
+
if (entries === undefined)
|
|
173
|
+
return { files, skipped };
|
|
174
|
+
const versionFrom = (name) => {
|
|
175
|
+
const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)/.exec(name);
|
|
176
|
+
return match?.[1];
|
|
177
|
+
};
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
const path = join(updaterDir, entry.name);
|
|
180
|
+
if (entry.isDirectory()) {
|
|
181
|
+
// Only `pending/` is updater residue; the directory itself stays.
|
|
182
|
+
if (entry.name !== 'pending')
|
|
183
|
+
continue;
|
|
184
|
+
const pending = await readdir(path, { withFileTypes: true }).catch(() => []);
|
|
185
|
+
for (const child of pending) {
|
|
186
|
+
if (!child.isFile())
|
|
187
|
+
continue;
|
|
188
|
+
const childPath = join(path, child.name);
|
|
189
|
+
const version = versionFrom(child.name);
|
|
190
|
+
if (version === undefined)
|
|
191
|
+
skipped.push({ path: childPath, reason: 'unknown-version' });
|
|
192
|
+
else if (version !== installedVersion)
|
|
193
|
+
skipped.push({ path: childPath, reason: 'other-version' });
|
|
194
|
+
else
|
|
195
|
+
files.push(childPath);
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!entry.isFile())
|
|
200
|
+
continue;
|
|
201
|
+
const isUpdaterFile = /^installer.*\.exe$/i.test(entry.name) || /\.blockmap$/i.test(entry.name);
|
|
202
|
+
if (!isUpdaterFile)
|
|
203
|
+
continue;
|
|
204
|
+
const version = versionFrom(entry.name);
|
|
205
|
+
if (version === undefined) {
|
|
206
|
+
skipped.push({ path, reason: 'unknown-version' });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (version !== installedVersion) {
|
|
210
|
+
skipped.push({ path, reason: 'other-version' });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
files.push(path);
|
|
214
|
+
}
|
|
215
|
+
return { files, skipped };
|
|
216
|
+
}
|
|
217
|
+
/** Everything under the request-image cache (kept apart from session attachments). */
|
|
218
|
+
async function attachmentCacheFiles(dshHome) {
|
|
219
|
+
const root = join(dshHome, 'cache', 'attachments');
|
|
220
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => undefined);
|
|
221
|
+
if (entries === undefined)
|
|
222
|
+
return [];
|
|
223
|
+
return entries.map((entry) => join(root, entry.name));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Inspect every bucket without touching anything.
|
|
227
|
+
* @param paths - resolved locations
|
|
228
|
+
* @returns per-target sizes plus the attachment totals kept apart from the cache
|
|
229
|
+
*/
|
|
230
|
+
export async function scan(paths) {
|
|
231
|
+
const items = [];
|
|
232
|
+
const skipped = [];
|
|
233
|
+
const browserDirs = await browserCacheDirs(paths.browserProfileDir);
|
|
234
|
+
const browser = { bytes: 0, files: 0 };
|
|
235
|
+
for (const dir of browserDirs) {
|
|
236
|
+
const own = await measure(dir);
|
|
237
|
+
browser.bytes += own.bytes;
|
|
238
|
+
browser.files += own.files;
|
|
239
|
+
}
|
|
240
|
+
items.push({ id: 'browser-cache', label: '浏览器缓存', ...browser });
|
|
241
|
+
const temp = { bytes: 0, files: 0 };
|
|
242
|
+
for (const dir of await tempResidueDirs(paths.tmpDir)) {
|
|
243
|
+
const own = await measure(dir);
|
|
244
|
+
temp.bytes += own.bytes;
|
|
245
|
+
temp.files += own.files;
|
|
246
|
+
}
|
|
247
|
+
items.push({ id: 'temp-residue', label: '临时残留', ...temp });
|
|
248
|
+
const updater = await updaterResidue(paths.updaterDir, paths.installedVersion);
|
|
249
|
+
const updaterSize = { bytes: 0, files: 0 };
|
|
250
|
+
for (const file of updater.files) {
|
|
251
|
+
const own = await measure(file);
|
|
252
|
+
updaterSize.bytes += own.bytes;
|
|
253
|
+
updaterSize.files += own.files;
|
|
254
|
+
}
|
|
255
|
+
items.push({
|
|
256
|
+
id: 'updater-residue',
|
|
257
|
+
label: '更新器残留',
|
|
258
|
+
...updaterSize,
|
|
259
|
+
...(updater.skipped.length > 0 ? { note: `${String(updater.skipped.length)} 项属于其它版本,跳过` } : {}),
|
|
260
|
+
});
|
|
261
|
+
const attachments = await measure(join(paths.dshHome, 'cache', 'attachments'));
|
|
262
|
+
items.push({
|
|
263
|
+
id: 'attachment-cache',
|
|
264
|
+
label: '图片变体缓存',
|
|
265
|
+
...attachments,
|
|
266
|
+
note: '发给模型前的缩放副本,可重建',
|
|
267
|
+
});
|
|
268
|
+
const objects = await measure(join(paths.dshHome, 'attachments', 'v1', 'objects'));
|
|
269
|
+
const requestImages = attachments;
|
|
270
|
+
return {
|
|
271
|
+
items,
|
|
272
|
+
totalBytes: items.reduce((sum, item) => sum + item.bytes, 0),
|
|
273
|
+
attachments: { objectsBytes: objects.bytes, requestImagesBytes: requestImages.bytes },
|
|
274
|
+
skipped,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Clean the requested buckets. With `dryRun` the same walk happens but nothing
|
|
279
|
+
* is renamed or removed, so the preview shows real numbers.
|
|
280
|
+
*
|
|
281
|
+
* @param options - resolved paths, enum targets and dry-run flag
|
|
282
|
+
* @returns per-target freed bytes and every skipped entry with a reason
|
|
283
|
+
*/
|
|
284
|
+
export async function clean(options) {
|
|
285
|
+
const now = options.now ?? Date.now();
|
|
286
|
+
const perTarget = [];
|
|
287
|
+
let freedBytes = 0;
|
|
288
|
+
for (const id of options.targets) {
|
|
289
|
+
const report = { id, freedBytes: 0, skipped: [] };
|
|
290
|
+
if (id === 'browser-cache') {
|
|
291
|
+
for (const dir of await browserCacheDirs(options.browserProfileDir)) {
|
|
292
|
+
const size = await measure(dir);
|
|
293
|
+
if (options.dryRun) {
|
|
294
|
+
report.freedBytes += size.bytes;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const outcome = await detachAndRemove(dir);
|
|
298
|
+
if (outcome.ok)
|
|
299
|
+
report.freedBytes += size.bytes;
|
|
300
|
+
else
|
|
301
|
+
report.skipped.push({ path: dir, reason: outcome.reason ?? 'permission' });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (id === 'temp-residue') {
|
|
305
|
+
for (const dir of await tempResidueDirs(options.tmpDir)) {
|
|
306
|
+
const stat = await lstat(dir).catch(() => undefined);
|
|
307
|
+
const age = stat === undefined ? 0 : now - stat.mtimeMs;
|
|
308
|
+
const size = await measure(dir);
|
|
309
|
+
if (stat !== undefined && age < TEMP_MIN_AGE_MS) {
|
|
310
|
+
// Too fresh to tell "left behind" from "this run is using it": prove it by
|
|
311
|
+
// renaming first; only a successful rename means the owner let go.
|
|
312
|
+
if (options.dryRun) {
|
|
313
|
+
report.skipped.push({ path: dir, reason: 'in-use' });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const outcome = await detachAndRemove(dir);
|
|
317
|
+
if (outcome.ok)
|
|
318
|
+
report.freedBytes += size.bytes;
|
|
319
|
+
else
|
|
320
|
+
report.skipped.push({ path: dir, reason: outcome.reason ?? 'in-use' });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (options.dryRun) {
|
|
324
|
+
report.freedBytes += size.bytes;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const outcome = await detachAndRemove(dir);
|
|
328
|
+
if (outcome.ok)
|
|
329
|
+
report.freedBytes += size.bytes;
|
|
330
|
+
else
|
|
331
|
+
report.skipped.push({ path: dir, reason: outcome.reason ?? 'permission' });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (id === 'updater-residue') {
|
|
335
|
+
const residue = await updaterResidue(options.updaterDir, options.installedVersion);
|
|
336
|
+
for (const skipped of residue.skipped)
|
|
337
|
+
report.skipped.push(skipped);
|
|
338
|
+
for (const file of residue.files) {
|
|
339
|
+
const size = await measure(file);
|
|
340
|
+
if (options.dryRun) {
|
|
341
|
+
report.freedBytes += size.bytes;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const removed = await removeEntry(file);
|
|
345
|
+
if (removed)
|
|
346
|
+
report.freedBytes += size.bytes;
|
|
347
|
+
else
|
|
348
|
+
report.skipped.push({ path: file, reason: 'permission' });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (id === 'attachment-cache') {
|
|
352
|
+
for (const path of await attachmentCacheFiles(options.dshHome)) {
|
|
353
|
+
const size = await measure(path);
|
|
354
|
+
if (options.dryRun) {
|
|
355
|
+
report.freedBytes += size.bytes;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const removed = await removeEntry(path);
|
|
359
|
+
if (removed)
|
|
360
|
+
report.freedBytes += size.bytes;
|
|
361
|
+
else
|
|
362
|
+
report.skipped.push({ path, reason: 'permission' });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
freedBytes += report.freedBytes;
|
|
366
|
+
perTarget.push(report);
|
|
367
|
+
}
|
|
368
|
+
return { dryRun: options.dryRun, freedBytes, perTarget };
|
|
369
|
+
}
|
|
370
|
+
/** Human-readable size for the card and the CLI. */
|
|
371
|
+
export function formatBytes(bytes) {
|
|
372
|
+
if (bytes < 1024)
|
|
373
|
+
return String(bytes) + ' B';
|
|
374
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
375
|
+
let value = bytes / 1024;
|
|
376
|
+
let unit = 0;
|
|
377
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
378
|
+
value /= 1024;
|
|
379
|
+
unit += 1;
|
|
380
|
+
}
|
|
381
|
+
return value.toFixed(value >= 100 ? 0 : 1) + ' ' + (units[unit] ?? 'TB');
|
|
382
|
+
}
|
|
383
|
+
/** Basename helper re-exported for the route's skipped-entry display. */
|
|
384
|
+
export function displayName(path) {
|
|
385
|
+
return basename(path);
|
|
386
|
+
}
|
package/lib/challenge.d.ts
CHANGED
|
@@ -21,6 +21,14 @@ export declare function detectChallenge(snapshot: ChallengeSnapshot): ChallengeM
|
|
|
21
21
|
* challenge. The browser window is visible on the user's desktop: the agent
|
|
22
22
|
* should pause and ask the user to complete it by hand, then re-snapshot.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* The hint appended when a challenge page is recognised. It has to know the
|
|
26
|
+
* window mode: headless has no window to complete a slider in, so telling the
|
|
27
|
+
* user to "finish it in the open window" would be false.
|
|
28
|
+
*
|
|
29
|
+
* @param headless - the mode the provider is running in
|
|
30
|
+
*/
|
|
31
|
+
export declare function challengeNote(headless: boolean): string;
|
|
24
32
|
export declare const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop \u2014 tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
|
|
25
33
|
/**
|
|
26
34
|
* Appended after an action that left the page byte-for-byte unchanged on a page
|
package/lib/challenge.js
CHANGED
|
@@ -213,6 +213,22 @@ export function detectChallenge(snapshot) {
|
|
|
213
213
|
* challenge. The browser window is visible on the user's desktop: the agent
|
|
214
214
|
* should pause and ask the user to complete it by hand, then re-snapshot.
|
|
215
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* The hint appended when a challenge page is recognised. It has to know the
|
|
218
|
+
* window mode: headless has no window to complete a slider in, so telling the
|
|
219
|
+
* user to "finish it in the open window" would be false.
|
|
220
|
+
*
|
|
221
|
+
* @param headless - the mode the provider is running in
|
|
222
|
+
*/
|
|
223
|
+
export function challengeNote(headless) {
|
|
224
|
+
if (headless) {
|
|
225
|
+
return ('[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. ' +
|
|
226
|
+
'The browser is running HEADLESS, so there is no window to finish it in — do not wait for a human to click anything here. ' +
|
|
227
|
+
'Stop and tell the user: either switch the window mode to 显示浏览器窗口 in Settings → 浏览器 and let them complete the verification, ' +
|
|
228
|
+
'or handle this site another way. Once a clearance cookie is obtained (in a headful window) it is saved to the profile and keeps working for the session.');
|
|
229
|
+
}
|
|
230
|
+
return CHALLENGE_NOTE;
|
|
231
|
+
}
|
|
216
232
|
export const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop — tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
|
|
217
233
|
/**
|
|
218
234
|
* Appended after an action that left the page byte-for-byte unchanged on a page
|
package/lib/client.js
CHANGED
|
@@ -233,10 +233,16 @@ window.__ModuleLoader__.load({
|
|
|
233
233
|
const [busy, setBusy] = useState(false);
|
|
234
234
|
const [msg, setMsg] = useState('');
|
|
235
235
|
const [logins, setLogins] = useState(null);
|
|
236
|
+
const [headless, setHeadless] = useState(null);
|
|
237
|
+
const [headlessUserChosen, setHeadlessUserChosen] = useState(false);
|
|
236
238
|
const [showAll, setShowAll] = useState(false);
|
|
237
239
|
const [openGroups, setOpenGroups] = useState([]);
|
|
238
240
|
const [hiddenDomains, setHiddenDomains] = useState(readHiddenDomains());
|
|
239
241
|
const [loginsOpen, setLoginsOpen] = useState(false);
|
|
242
|
+
const [disk, setDisk] = useState(null);
|
|
243
|
+
const [diskPicked, setDiskPicked] = useState({ 'browser-cache': true, 'temp-residue': true, 'updater-residue': true, 'attachment-cache': false });
|
|
244
|
+
const [diskPreview, setDiskPreview] = useState(null);
|
|
245
|
+
const [diskResult, setDiskResult] = useState(null);
|
|
240
246
|
const mountedRef = useRef(true);
|
|
241
247
|
|
|
242
248
|
const refresh = async () => {
|
|
@@ -248,6 +254,8 @@ window.__ModuleLoader__.load({
|
|
|
248
254
|
if (typeof s.engine === 'string')
|
|
249
255
|
setEngine(s.engine);
|
|
250
256
|
setEngineUserChosen(s.engineUserChosen === true);
|
|
257
|
+
if (typeof s.headless === 'boolean') setHeadless(s.headless);
|
|
258
|
+
setHeadlessUserChosen(s.headlessUserChosen === true);
|
|
251
259
|
setGates({
|
|
252
260
|
allowEvaluate: typeof s.allowEvaluate === 'boolean' ? s.allowEvaluate : null,
|
|
253
261
|
allowCdp: typeof s.allowCdp === 'boolean' ? s.allowCdp : null,
|
|
@@ -276,6 +284,7 @@ window.__ModuleLoader__.load({
|
|
|
276
284
|
mountedRef.current = true;
|
|
277
285
|
refresh();
|
|
278
286
|
refreshLogins();
|
|
287
|
+
refreshDisk();
|
|
279
288
|
const timer = setInterval(refresh, 5000);
|
|
280
289
|
// Cookies change far more slowly than switches; polling this less often keeps the
|
|
281
290
|
// settings page from doing pointless work.
|
|
@@ -331,6 +340,27 @@ window.__ModuleLoader__.load({
|
|
|
331
340
|
}
|
|
332
341
|
};
|
|
333
342
|
|
|
343
|
+
const chooseWindow = async (nextHeadless) => {
|
|
344
|
+
if (headless === null || busy || nextHeadless === headless) return;
|
|
345
|
+
setBusy(true);
|
|
346
|
+
setMsg('');
|
|
347
|
+
try {
|
|
348
|
+
const r = await api('/window', 'POST', { headless: nextHeadless });
|
|
349
|
+
if (r && r.error) throw new Error(r.error.message || r.error.code || 'unknown');
|
|
350
|
+
if (typeof r.headless === 'boolean') setHeadless(r.headless);
|
|
351
|
+
setHeadlessUserChosen(true);
|
|
352
|
+
setMsg(r.changed
|
|
353
|
+
? (nextHeadless
|
|
354
|
+
? '已切到后台运行(无头)。下次操作时重开浏览器;标签页会关闭,登录状态保留。'
|
|
355
|
+
: '已切到显示窗口。下次操作时重开浏览器;标签页会关闭,登录状态保留。')
|
|
356
|
+
: '状态未变化');
|
|
357
|
+
} catch (error) {
|
|
358
|
+
setMsg('切换失败:' + (error?.message ?? String(error)));
|
|
359
|
+
} finally {
|
|
360
|
+
setBusy(false);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
334
364
|
const applyGates = async (key, next) => {
|
|
335
365
|
if (gates[key] === null || busy || next === gates[key])
|
|
336
366
|
return;
|
|
@@ -366,6 +396,58 @@ window.__ModuleLoader__.load({
|
|
|
366
396
|
}
|
|
367
397
|
};
|
|
368
398
|
|
|
399
|
+
const fmt = (bytes) => {
|
|
400
|
+
if (typeof bytes !== 'number') return '—';
|
|
401
|
+
if (bytes < 1024) return bytes + ' B';
|
|
402
|
+
const units = ['KB', 'MB', 'GB'];
|
|
403
|
+
let value = bytes / 1024;
|
|
404
|
+
let unit = 0;
|
|
405
|
+
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
|
|
406
|
+
return value.toFixed(value >= 100 ? 0 : 1) + ' ' + units[unit];
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const refreshDisk = async () => {
|
|
410
|
+
try {
|
|
411
|
+
const d = await api('/disk/stat', 'GET');
|
|
412
|
+
if (mountedRef.current && d && !d.error) setDisk(d);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (mountedRef.current) setMsg('磁盘统计失败:' + (error?.message ?? String(error)));
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const runDisk = async (dryRun) => {
|
|
419
|
+
if (busy) return;
|
|
420
|
+
const targets = Object.keys(diskPicked).filter((id) => diskPicked[id]);
|
|
421
|
+
if (targets.length === 0) { setMsg('先勾选要清理的项目'); return; }
|
|
422
|
+
setBusy(true);
|
|
423
|
+
setMsg('');
|
|
424
|
+
try {
|
|
425
|
+
const r = await api('/disk/clean', 'POST', { targets, dryRun });
|
|
426
|
+
if (r && r.error) throw new Error(r.error.message || r.error.code || 'unknown');
|
|
427
|
+
if (dryRun) {
|
|
428
|
+
setDiskPreview(r);
|
|
429
|
+
setDiskResult(null);
|
|
430
|
+
} else {
|
|
431
|
+
setDiskPreview(null);
|
|
432
|
+
setDiskResult(r);
|
|
433
|
+
setMsg('已释放 ' + fmt(r.freedBytes) + '(跳过 ' + (r.perTarget || []).reduce((n, t) => n + ((t.skipped && t.skipped.length) || 0), 0) + ' 项)');
|
|
434
|
+
refreshDisk();
|
|
435
|
+
}
|
|
436
|
+
} catch (error) {
|
|
437
|
+
setMsg('清理失败:' + (error?.message ?? String(error)));
|
|
438
|
+
} finally {
|
|
439
|
+
setBusy(false);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const skipSummary = (report) => {
|
|
444
|
+
if (!report || !Array.isArray(report.perTarget)) return '';
|
|
445
|
+
const reasons = {};
|
|
446
|
+
for (const t of report.perTarget) for (const s of (t.skipped || [])) reasons[s.reason] = (reasons[s.reason] || 0) + 1;
|
|
447
|
+
const parts = Object.keys(reasons).map((k) => k + ' ' + reasons[k]);
|
|
448
|
+
return parts.length > 0 ? ' 跳过:' + parts.join('、') : '';
|
|
449
|
+
};
|
|
450
|
+
|
|
369
451
|
const state = enabled === null ? 'ongoing' : (enabled ? 'done' : 'idle');
|
|
370
452
|
const gateState = (key) => (gates[key] === null ? 'ongoing' : (gates[key] ? 'done' : 'idle'));
|
|
371
453
|
const gatesOn = GATE_ROWS.filter((row) => gates[row.key] === true).length;
|
|
@@ -423,6 +505,40 @@ window.__ModuleLoader__.load({
|
|
|
423
505
|
o.label + ':' + o.hint))),
|
|
424
506
|
}),
|
|
425
507
|
|
|
508
|
+
// ---- 浏览器窗口(有头 / 无头) ----
|
|
509
|
+
h('div', { key: 'windowGroup', style: S.group }, [
|
|
510
|
+
'浏览器窗口',
|
|
511
|
+
h('span', { key: 'cur', style: S.groupCount },
|
|
512
|
+
headless === null ? ' 读取中…' : (headless ? ' 当前:后台运行(无头)' : ' 当前:显示窗口(有头)')
|
|
513
|
+
+ (headlessUserChosen ? '(你选的)' : '')),
|
|
514
|
+
]),
|
|
515
|
+
h('div', { key: 'windows', style: { ...S.list, marginTop: 8 } }, [
|
|
516
|
+
h(RadioRow, {
|
|
517
|
+
key: 'headful',
|
|
518
|
+
p: { id: 'headful', label: '显示浏览器窗口', hint: '能看见、能随时接手;登录和验证码可以自己处理' },
|
|
519
|
+
selected: headless === false,
|
|
520
|
+
disabled: headless === null || busy,
|
|
521
|
+
onPick: () => chooseWindow(false),
|
|
522
|
+
}),
|
|
523
|
+
h(RadioRow, {
|
|
524
|
+
key: 'headless',
|
|
525
|
+
p: { id: 'headless', label: '后台运行(无头)', hint: '更快、不弹窗;但你看不见它,登录/验证码只能远程处理' },
|
|
526
|
+
selected: headless === true,
|
|
527
|
+
disabled: headless === null || busy,
|
|
528
|
+
onPick: () => chooseWindow(true),
|
|
529
|
+
}),
|
|
530
|
+
]),
|
|
531
|
+
h(Notes, {
|
|
532
|
+
key: 'windowNotes',
|
|
533
|
+
title: '切换窗口模式会发生什么',
|
|
534
|
+
children: h('div', null, [
|
|
535
|
+
h('p', { key: 'reopen', style: S.note },
|
|
536
|
+
'有头/无头是启动参数,不能原地切换:下次操作时会关掉旧窗口、按新模式重开。标签页和未提交的表单会丢,登录状态会先导出、不会丢。'),
|
|
537
|
+
h('p', { key: 'risk', style: S.note },
|
|
538
|
+
'无头模式下插件"看得见、能接管"的前提不成立——验证码/二次验证只有提示,没人能在窗口里替你点。只在无人值守、且你接受这个代价时用。'),
|
|
539
|
+
]),
|
|
540
|
+
}),
|
|
541
|
+
|
|
426
542
|
// ---- 工具权限 ----
|
|
427
543
|
h('div', { key: 'gateGroup', style: S.group }, [
|
|
428
544
|
'工具权限',
|
|
@@ -459,6 +575,55 @@ window.__ModuleLoader__.load({
|
|
|
459
575
|
]),
|
|
460
576
|
}),
|
|
461
577
|
|
|
578
|
+
h('div', { key: 'diskGroup', style: S.group }, [
|
|
579
|
+
'缓存与磁盘',
|
|
580
|
+
h('span', { key: 'sum', style: S.groupCount },
|
|
581
|
+
disk === null ? ' 统计中…' : ' 可清理 ' + fmt(disk.totalBytes)),
|
|
582
|
+
]),
|
|
583
|
+
h('div', { key: 'diskList', style: { ...S.list, marginTop: 8 } },
|
|
584
|
+
(disk === null ? [] : disk.items).map((item) => h('div', { key: item.id, style: S.row }, [
|
|
585
|
+
h('div', {
|
|
586
|
+
key: 'check',
|
|
587
|
+
role: 'checkbox',
|
|
588
|
+
'aria-checked': diskPicked[item.id] === true,
|
|
589
|
+
'aria-label': item.label,
|
|
590
|
+
tabIndex: 0,
|
|
591
|
+
onClick: () => setDiskPicked({ ...diskPicked, [item.id]: !diskPicked[item.id] }),
|
|
592
|
+
onKeyDown: (e) => {
|
|
593
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
594
|
+
e.preventDefault();
|
|
595
|
+
setDiskPicked({ ...diskPicked, [item.id]: !diskPicked[item.id] });
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
style: {
|
|
599
|
+
width: 16, height: 16, flex: 'none', borderRadius: 4, cursor: 'pointer',
|
|
600
|
+
border: '1.5px solid ' + (diskPicked[item.id] ? T.primary : T.borderL3),
|
|
601
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
602
|
+
},
|
|
603
|
+
}, diskPicked[item.id] ? h('span', { style: { width: 8, height: 8, borderRadius: 2, background: T.primary } }) : null),
|
|
604
|
+
h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
|
|
605
|
+
h('div', { key: 'label', style: S.rowTitle }, item.label),
|
|
606
|
+
h('div', { key: 'desc', style: S.rowDesc },
|
|
607
|
+
fmt(item.bytes) + ' ' + item.files + ' 个文件'
|
|
608
|
+
+ (item.note ? ' ' + item.note : '')),
|
|
609
|
+
]),
|
|
610
|
+
]))),
|
|
611
|
+
h('div', { key: 'diskActions', style: S.footer }, [
|
|
612
|
+
h(Btn, { key: 'rescan', onClick: refreshDisk, disabled: busy }, '重新统计'),
|
|
613
|
+
h(Btn, { key: 'preview', onClick: () => runDisk(true), disabled: busy || disk === null }, '先预演'),
|
|
614
|
+
diskPreview
|
|
615
|
+
? h(Btn, { key: 'clean', onClick: () => runDisk(false), disabled: busy }, '确认清理 ' + fmt(diskPreview.freedBytes))
|
|
616
|
+
: null,
|
|
617
|
+
]),
|
|
618
|
+
diskPreview
|
|
619
|
+
? h('div', { key: 'diskPreview', style: S.tip },
|
|
620
|
+
'预演:将释放 ' + fmt(diskPreview.freedBytes) + skipSummary(diskPreview) + '(点了确认才会真删)')
|
|
621
|
+
: null,
|
|
622
|
+
diskResult
|
|
623
|
+
? h('div', { key: 'diskResult', style: S.tip },
|
|
624
|
+
'已释放 ' + fmt(diskResult.freedBytes) + skipSummary(diskResult))
|
|
625
|
+
: null,
|
|
626
|
+
|
|
462
627
|
// ---- 已登录站点(默认收起) ----
|
|
463
628
|
h('div', {
|
|
464
629
|
key: 'loginsGroup',
|
package/lib/errors.d.ts
CHANGED
|
@@ -23,3 +23,15 @@ export declare class BrowserError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
/** Wrap an unknown cause into a launch failure with the code preserved. */
|
|
25
25
|
export declare function launchFailed(cause: unknown): BrowserError;
|
|
26
|
+
/**
|
|
27
|
+
* "browser_evaluate is switched off". One wording, thrown by both layers that
|
|
28
|
+
* enforce it: the tool layer (before a browser is even launched) and the
|
|
29
|
+
* session itself (so a programmatic caller cannot go around the switch).
|
|
30
|
+
* @returns the denial, ready to throw.
|
|
31
|
+
*/
|
|
32
|
+
export declare function evaluateDisabled(): BrowserError;
|
|
33
|
+
/**
|
|
34
|
+
* "browser_cdp is switched off". Same two-layer rule as {@link evaluateDisabled}.
|
|
35
|
+
* @returns the denial, ready to throw.
|
|
36
|
+
*/
|
|
37
|
+
export declare function cdpDisabled(): BrowserError;
|