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.
@@ -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
+ }
@@ -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;