vcorp-chrome 1.3.0 → 1.4.1

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.
@@ -3,8 +3,9 @@
3
3
  // 和本机那条桥是两回事:桥只有本机进程能连,agent 拿到的是全套工具;
4
4
  // 云端通道是**这个浏览器主动连出去**的一条 WebSocket,对面是公网上的服务器。
5
5
  // 所以这里的每一条命令都按「对面可能已经被攻破」来处理,规矩全在 cloud-policy.js:
6
- // 只有页面操作那一小组命令、只能停在用户勾选的平台(以及本任务里逐个批准的额外网站)上、只能碰自己开的标签页、
7
- // 上传只能用服务器上的素材、每个任务要用户点头。
6
+ // 只有页面操作那一小组命令、只能停在用户勾选的平台(以及本任务里批准的网站)上、只能碰自己开的标签页、
7
+ // 上传只能用服务器上的素材。新任务要用户点头;开了自动允许则新任务和它一开始的网站直接放行。
8
+ // 任务开始之后追加的网站、本地文件夹,仍要逐个点头。
8
9
  //
9
10
  // 身份:配对时在这里生成一对 ECDSA P-256 密钥,私钥以不可导出的 CryptoKey 存在
10
11
  // IndexedDB,服务器只拿到公钥。每次连接对服务器给的随机数签名,签名里带上服务器
@@ -14,6 +15,7 @@
14
15
  // 20 秒一次心跳足够;SW 真被回收了,30 秒的 alarm 会把它连回来(见 background.js)。
15
16
 
16
17
  import * as policy from './cloud-policy.js';
18
+ import * as folders from './folders.js';
17
19
 
18
20
  const CFG = 'cloud'; // storage.local:配对信息和用户的选择
19
21
  const TASKS = 'cloudTasks'; // storage.local:任务(批准状态、自己开的标签页)
@@ -37,6 +39,7 @@ let connecting = false;
37
39
  const waiters = new Map(); // jobId → Set<fn(allow)>,等用户批准的命令
38
40
  const recent = []; // 最近一分钟的命令时间戳
39
41
  let lock = Promise.resolve();
42
+ let mountWin = null;
40
43
 
41
44
  const err = (code, message) => Object.assign(new Error(message), { code });
42
45
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -154,6 +157,8 @@ async function confirmClaim() {
154
157
  });
155
158
  await chrome.storage.session.remove(CLAIM);
156
159
  await log({ ev: 'paired', account: r.account || c.account, origin: c.origin });
160
+ clearTimeout(retryTimer); retryTimer = null;
161
+ retryDelay = 0;
157
162
  void connect();
158
163
  return getCfg();
159
164
  }
@@ -183,27 +188,42 @@ function disconnect() {
183
188
  ws = null;
184
189
  }
185
190
 
186
- function scheduleReconnect() {
191
+ function scheduleReconnect(minDelay = 0) {
187
192
  if (retryTimer) return;
188
- retryDelay = retryDelay ? Math.min(retryDelay * 2, 60000) : 1000;
193
+ const grown = retryDelay ? Math.min(retryDelay * 2, 60000) : 1000;
194
+ retryDelay = Math.max(grown, minDelay);
189
195
  retryTimer = setTimeout(() => { retryTimer = null; void connect(); }, retryDelay);
190
196
  }
191
197
 
198
+ function socketUrl(origin) {
199
+ const u = new URL(origin);
200
+ u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
201
+ u.pathname = '/api/ext/ws';
202
+ u.search = '';
203
+ u.hash = '';
204
+ return u.href;
205
+ }
206
+
192
207
  export async function connect() {
193
- if (connecting || (ws && ws.readyState <= 1)) return;
208
+ // readyState 2 是 CLOSING。只挡 0 和 1 的话,旧连接还在关、新连接已经握上,
209
+ // 代理会把叠在一起的升级请求回 503。
210
+ if (connecting || retryTimer || (ws && ws.readyState !== 3)) return;
194
211
  const cfg = await getCfg();
195
212
  if (!cfg?.deviceId) return setStatus('off');
196
213
  if (cfg.paused) return setStatus('paused', '已暂停:云端命令一律不接');
197
214
  connecting = true;
198
215
  let sock;
216
+ let opened = false;
199
217
  try {
200
218
  await setStatus('connecting');
201
- // 握手失败(例如代理回 503)不会抛异常,Chrome 仍会在内部记一条网络错误。
202
- // 这里能接住的是构造函数抛出的地址错误,以及后面每条消息里的异常,都不进侧栏的红色错误。
203
- try { sock = new WebSocket(cfg.origin.replace(/^http/, 'ws') + '/api/ext/ws'); }
219
+ // 地址不合法时构造函数会抛,接住后重连。握手被拒(代理 503 等)不会抛:
220
+ // Chrome 把 HTTP 状态写进自己的网络日志,脚本取消不了那一行。
221
+ // 这种失败走下面的 onclose,并且把重试间隔拉到至少 15 秒,避免每秒一条。
222
+ try { sock = new WebSocket(socketUrl(cfg.origin)); }
204
223
  catch { scheduleReconnect(); return; }
205
224
  ws = sock;
206
225
  lastRx = Date.now();
226
+ sock.onopen = () => { opened = true; };
207
227
  sock.onerror = (ev) => { ev?.preventDefault?.(); };
208
228
  sock.onmessage = (ev) => {
209
229
  void (async () => {
@@ -217,9 +237,11 @@ export async function connect() {
217
237
  if (m.type === 'challenge') {
218
238
  try {
219
239
  const sig = await sign(helloText(cfg.origin, cfg.deviceId, String(m.nonce || '')));
240
+ const mounted = await folders.names().catch(() => []);
220
241
  sock.send(JSON.stringify({
221
242
  type: 'hello', v: 1, deviceId: cfg.deviceId, sig,
222
243
  version: chrome.runtime.getManifest().version, platforms: cfg.platforms || [], auto: !!cfg.auto,
244
+ folders: mounted,
223
245
  }));
224
246
  } catch { try { sock.close(); } catch { /* 已经废了 */ } }
225
247
  return;
@@ -233,6 +255,7 @@ export async function connect() {
233
255
  if (m.type === 'revoked') { await unpair(); await setStatus('off', '这个浏览器已在网页上被移除'); return; }
234
256
  if (m.type === 'cmd') { await handle(m); return; }
235
257
  if (m.type === 'probe') { await handleProbe(m); return; }
258
+ if (m.type === 'mount_request') { await openMount(); return; }
236
259
  if (m.type === 'job_end') { await endTask(String(m.jobId || ''), 'server'); }
237
260
  } catch { /* 一条消息处理失败不能变成未捕获异常 */ }
238
261
  })();
@@ -243,7 +266,7 @@ export async function connect() {
243
266
  clearInterval(pingTimer); pingTimer = null;
244
267
  if (ev.code === 4401) { void setStatus('error', '服务器不认这个设备的签名,请重新配对'); return; }
245
268
  void setStatus('connecting');
246
- scheduleReconnect();
269
+ scheduleReconnect(opened ? 0 : 15000);
247
270
  };
248
271
  clearInterval(pingTimer);
249
272
  pingTimer = setInterval(() => {
@@ -263,7 +286,7 @@ export async function connect() {
263
286
 
264
287
  // alarm 每 30 秒叫一次:配了对、没暂停、却没连着,就连
265
288
  export async function ensureCloud() {
266
- if (ws && ws.readyState <= 1) return;
289
+ if (retryTimer || (ws && ws.readyState !== 3)) return;
267
290
  const cfg = await getCfg();
268
291
  if (cfg?.deviceId && !cfg.paused) await connect();
269
292
  }
@@ -284,7 +307,7 @@ async function pushLive() {
284
307
  deps?.setCloudLive(Object.values(tasks).filter((t) => t.state === 'approved').map((t) => sidOf(t.jobId)));
285
308
  }
286
309
 
287
- // 通用任务(信封上带 sites 而不是 platforms):能去哪些站点由用户逐个批准。
310
+ // 通用任务(信封上带 sites):一开始的网站清单随任务一起批准。
288
311
  // 发布任务的白名单是勾选平台的根域,加上这个任务里另行批准的额外网站。
289
312
  const isBrowse = (job) => Array.isArray(job?.sites);
290
313
  const hostsOf = (t) => policy.taskHosts(t);
@@ -293,12 +316,14 @@ const hostsOf = (t) => policy.taskHosts(t);
293
316
  // 浏览器任务(信封带 sites)或用户后来勾了平台,都要能重新评,不能把任务号卡死。
294
317
  const STALE_DENY = '这个任务要用的平台你都没有在侧栏里勾选';
295
318
 
296
- // 批准与否只由用户决定(或用户主动开了自动允许;通用任务和额外网站不算)。服务器只能申请。
319
+ // 新任务由用户点头,或由用户打开的自动允许直接放行(含通用任务信封上一开始的网站)。
320
+ // 任务开始之后再申请的网站、本地文件夹,自动允许管不到。服务器只能申请。
297
321
  // reason:通用任务第一次申请站点时 agent 写的用途,放进批准框里给用户看。
298
- async function taskFor(job, cfg, reason = '') {
322
+ async function taskFor(job, cfg, reason = '', { allowEmpty = false } = {}) {
299
323
  const jobId = String(job?.id || '');
300
324
  if (!/^[\w-]{1,64}$/.test(jobId)) throw err('CLOUD_DENIED', '命令缺少有效的任务号');
301
325
  const title = String(job?.title || '').slice(0, 80);
326
+ let freshAuto = false;
302
327
 
303
328
  const t = await serial(async () => {
304
329
  const tasks = await getTasks();
@@ -309,10 +334,14 @@ async function taskFor(job, cfg, reason = '') {
309
334
  if (task?.state === 'approved' || task?.state === 'pending') return task;
310
335
  if (isBrowse(job)) {
311
336
  const { sites } = policy.cleanSites(job.sites);
312
- if (!sites.length) return { state: 'nosites' };
337
+ // 只读本地文件夹的任务可以还没有网站。文件夹本身仍要单独点头,这里不替用户答应。
338
+ if (!sites.length && !allowEmpty) return { state: 'nosites' };
339
+ const opening = policy.initialTask({ browse: true, sites, auto: !!cfg.auto });
340
+ freshAuto = !!cfg.auto && opening.state === 'approved' && sites.length > 0;
313
341
  task = {
314
- jobId, title, kind: 'browse', requested: [], platforms: [], sites: [], ask: sites, refused: [], purpose: reason,
315
- tabs: [], cur: null, count: 0, state: 'pending', createdAt: Date.now(), lastAt: Date.now(), why: '',
342
+ jobId, title, kind: 'browse', requested: [], platforms: [], sites: opening.sites, ask: opening.ask, refused: [], purpose: reason,
343
+ folders: [], folderRefused: [], folderAsk: [], folderBytes: 0,
344
+ tabs: [], cur: null, count: 0, state: opening.state, createdAt: Date.now(), lastAt: Date.now(), why: '',
316
345
  };
317
346
  tasks[jobId] = task;
318
347
  await setTasks(tasks);
@@ -320,24 +349,41 @@ async function taskFor(job, cfg, reason = '') {
320
349
  }
321
350
  const requested = (Array.isArray(job?.platforms) ? job.platforms : []).map(String);
322
351
  const platforms = policy.grantedPlatforms(requested, cfg.platforms);
352
+ const opening = policy.initialTask({ platforms, auto: !!cfg.auto });
353
+ freshAuto = !!cfg.auto && opening.state === 'approved' && platforms.length > 0;
323
354
  task = {
324
- jobId, title, requested, platforms, sites: [], refused: [], ask: [],
325
- tabs: [], cur: null, count: 0,
326
- state: platforms.length ? (cfg.auto ? 'approved' : 'pending') : 'denied',
327
- createdAt: Date.now(), lastAt: Date.now(),
328
- why: platforms.length ? '' : STALE_DENY,
355
+ jobId, title, requested, platforms, sites: opening.sites, refused: [], ask: opening.ask,
356
+ folders: [], folderRefused: [], folderAsk: [], folderBytes: 0,
357
+ tabs: [], cur: null, count: 0, state: opening.state, createdAt: Date.now(), lastAt: Date.now(),
358
+ why: opening.state === 'denied' ? STALE_DENY : '',
329
359
  };
330
360
  tasks[jobId] = task;
331
361
  await setTasks(tasks);
332
362
  return task;
333
363
  });
334
364
 
335
- if (t.state === 'nosites') throw err('NO_SITES', '通用任务要先用 sites 申请要访问的网站(如 sites {"add":["zhipin.com"],"reason":"找兼职"}),用户批准后才能打开。');
365
+ if (freshAuto) {
366
+ await log({ ev: 'approved', job: jobId, msg: '自动放行' });
367
+ await pushLive();
368
+ }
369
+ if (t.state === 'nosites') throw err('NO_SITES', '通用任务要先用 sites 申请要访问的网站(如 sites {"add":["zhipin.com"],"reason":"找兼职"}),或用 folders 申请本地文件夹。用户批准后才能继续。');
336
370
  if (t.state === 'denied') throw err('CLOUD_DENIED', `用户拒绝了这个任务${t.why ? `:${t.why}` : ''}。不要重试。`);
337
371
  if (t.state === 'approved') return t;
338
372
 
373
+ // 这条任务还挂着。开关若已经打开(包括刚打开、存储里已经是开的),按允许处理,不再弹窗。
374
+ const live = await getCfg();
375
+ if (cfg.auto || live?.auto) {
376
+ await decide(jobId, true, { auto: true });
377
+ const granted = (await getTasks())[jobId];
378
+ if (granted?.state === 'approved') return granted;
379
+ }
380
+ const now = (await getTasks())[jobId];
381
+ if (!now) throw err('CLOUD_DENIED', '这个任务已经结束');
382
+ if (now.state === 'denied') throw err('CLOUD_DENIED', `用户拒绝了这个任务${now.why ? `:${now.why}` : ''}。不要重试。`);
383
+ if (now.state === 'approved') return now;
384
+
339
385
  // 等用户点头。通知是把人叫回来的唯一办法;侧栏里同样有一张卡片
340
- notifyPending(t, cfg);
386
+ notifyPending(now, live || cfg);
341
387
  const allow = await waitFor(jobId);
342
388
  if (allow === null) throw err('CLOUD_TIMEOUT', '用户 5 分钟内没有批准这个任务。告诉用户打开浏览器扩展侧栏批准后再继续。');
343
389
  if (!allow) throw err('CLOUD_DENIED', '用户拒绝了这个任务。不要重试。');
@@ -420,25 +466,152 @@ async function requestSites(task, p, cfg) {
420
466
  return { text: lines.join('\n'), sites };
421
467
  }
422
468
 
469
+ function notifyFolders(t, cfg) {
470
+ const kind = t.kind === 'browse' ? '任务' : '发布任务';
471
+ chrome.notifications?.create(`hc-cloud-folders:${t.jobId}`, {
472
+ type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
473
+ title: `${policy.hostOf(cfg.origin)} 的${kind}想读取你的本地文件夹`,
474
+ message: `${t.title ? `「${t.title}」` : kind} → ${(t.folderAsk || []).join('、')}${t.folderAskWhy ? `\n用途:${t.folderAskWhy}` : ''}`,
475
+ buttons: [{ title: '允许' }, { title: '拒绝' }],
476
+ }, () => void chrome.runtime.lastError);
477
+ }
478
+
479
+ // 和额外网站一样:「自动允许」管不到文件夹。没挂载的名字不弹窗,直接告诉 agent。
480
+ async function requestFolders(task, p, cfg) {
481
+ const mounted = await folders.names().catch(() => []);
482
+ const have = new Set(mounted.map((f) => f.name));
483
+ const known = p.use.filter((name) => have.has(name));
484
+ const unknown = p.use.filter((name) => !have.has(name));
485
+ const split = policy.foldersToAsk(known, { granted: task.folders || [], refused: task.folderRefused || [] });
486
+ if (split.ask.length) {
487
+ const t = await updateTask(task.jobId, (x) => {
488
+ if (!Array.isArray(x.folders)) x.folders = [];
489
+ if (!Array.isArray(x.folderRefused)) x.folderRefused = [];
490
+ x.folderAsk = split.ask;
491
+ x.folderAskWhy = p.reason;
492
+ });
493
+ if (!t) throw err('CLOUD_DENIED', '这个任务已经结束');
494
+ notifyFolders(t, cfg);
495
+ const allow = await waitFor(`folders:${task.jobId}`);
496
+ await updateTask(task.jobId, (x) => {
497
+ if (!Array.isArray(x.folders)) x.folders = [];
498
+ if (!Array.isArray(x.folderRefused)) x.folderRefused = [];
499
+ if (allow) for (const name of split.ask) if (!x.folders.includes(name) && x.folders.length < policy.FS_FOLDERS_MAX) x.folders.push(name);
500
+ else if (allow === false) for (const name of split.ask) if (!x.folderRefused.includes(name)) x.folderRefused.push(name);
501
+ x.folderAsk = [];
502
+ x.folderAskWhy = '';
503
+ });
504
+ chrome.notifications?.clear(`hc-cloud-folders:${task.jobId}`, () => void chrome.runtime.lastError);
505
+ await log({ ev: allow ? 'folders_on' : allow === false ? 'folders_off' : 'folders_timeout', job: task.jobId, msg: split.ask.join(',') });
506
+ }
507
+ const now = (await getTasks())[task.jobId] || task;
508
+ const granted = now.folders || [];
509
+ const refused = now.folderRefused || [];
510
+ const lines = [`已挂载:${mounted.map((f) => f.name).join('、') || '(无)'}`];
511
+ lines.push(`本任务可以读:${granted.join('、') || '(无)'}`);
512
+ const no = known.filter((name) => refused.includes(name));
513
+ const later = split.ask.filter((name) => !granted.includes(name) && !refused.includes(name));
514
+ if (no.length) lines.push(`用户不同意:${no.join('、')}。不要再申请。`);
515
+ if (later.length) lines.push(`用户 5 分钟内没有回应:${later.join('、')}。可以稍后再申请一次。`);
516
+ if (unknown.length) lines.push(`这个浏览器没有挂载:${unknown.join('、')}。请用户在网页上点「挂载本地文件夹」。`);
517
+ if (p.bad.length) lines.push(`不是合法的文件夹名:${p.bad.join('、')}`);
518
+ return { text: lines.join('\n'), folders: granted, audit: `folders ${p.use.join(',') || '(list)'}` };
519
+ }
520
+
521
+ function decideFolders(jobId, allow) {
522
+ chrome.notifications?.clear(`hc-cloud-folders:${jobId}`, () => void chrome.runtime.lastError);
523
+ for (const fn of waiters.get(`folders:${jobId}`) || []) fn(!!allow);
524
+ }
525
+
526
+ async function notifyLocked(entry) {
527
+ chrome.notifications?.create(`hc-folder:${entry.id}`, {
528
+ type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
529
+ title: '本地文件夹需要重新允许',
530
+ message: `「${entry.name}」的读取权限失效了。点这里打开窗口,再点「恢复访问」。`,
531
+ }, () => void chrome.runtime.lastError);
532
+ await openMount(entry.id).catch(() => {});
533
+ }
534
+
535
+ async function fsCmd(cmd, p, task) {
536
+ const granted = task.folders || [];
537
+ if (!granted.includes(p.folder)) {
538
+ throw err('CLOUD_DENIED', `本任务还不能读「${p.folder}」。先用 folders 申请,用户点头之后再读。`);
539
+ }
540
+ const entry = await folders.getByName(p.folder);
541
+ if (!entry?.handle) throw err('CLOUD_DENIED', `这个浏览器上没有「${p.folder}」。请用户在网页上重新挂载。`);
542
+ try {
543
+ if (cmd === 'fs_list') return await folders.listTree(entry.handle, { path: p.path, depth: p.depth, glob: p.glob, folder: p.folder });
544
+ const file = await folders.readFile(entry.handle, p.path);
545
+ let reserved = false;
546
+ const held = await updateTask(task.jobId, (x) => {
547
+ const used = x.folderBytes || 0;
548
+ if (used + file.size > policy.FS_TASK_MAX) return;
549
+ x.folderBytes = used + file.size;
550
+ reserved = true;
551
+ });
552
+ if (!held) throw err('CLOUD_DENIED', '这个任务已经结束');
553
+ if (!reserved) throw err('CLOUD_DENIED', '这个任务读取本地文件已到 256MB 上限');
554
+ try {
555
+ const up = await folders.uploadFile(file, p.upload.url);
556
+ return { text: `已读取 ${p.folder}/${p.path}(${up.bytes} 字节),可以拉取了。`, bytes: up.bytes, name: up.name, audit: `read ${p.folder}/${p.path} ${up.bytes}` };
557
+ } catch (e) {
558
+ await updateTask(task.jobId, (x) => { x.folderBytes = Math.max(0, (x.folderBytes || 0) - file.size); });
559
+ throw e;
560
+ }
561
+ } catch (e) {
562
+ if (e.code === 'FOLDER_LOCKED') {
563
+ await notifyLocked(entry);
564
+ throw err('FOLDER_LOCKED', `文件夹「${entry.name}」需要你重新允许一次。已打开窗口,点「恢复访问」后再试。`);
565
+ }
566
+ throw e;
567
+ }
568
+ }
569
+
570
+ async function openMount(folderId = '') {
571
+ const url = chrome.runtime.getURL('mount.html') + (folderId ? `?id=${encodeURIComponent(folderId)}` : '');
572
+ if (mountWin != null) {
573
+ const existing = await chrome.windows.get(mountWin).catch(() => null);
574
+ if (existing) {
575
+ await chrome.windows.update(mountWin, { focused: true, drawAttention: true }).catch(() => {});
576
+ const [tab] = await chrome.tabs.query({ windowId: mountWin });
577
+ if (tab) await chrome.tabs.update(tab.id, { url }).catch(() => {});
578
+ return;
579
+ }
580
+ mountWin = null;
581
+ }
582
+ const created = await chrome.windows.create({ url, type: 'popup', width: 440, height: 560, focused: true });
583
+ mountWin = created?.id ?? null;
584
+ }
585
+
586
+ async function reportFolders() {
587
+ const mounted = await folders.names().catch(() => []);
588
+ const cfg = await getCfg();
589
+ if (cfg && ws?.readyState === 1) send({ type: 'config', platforms: cfg.platforms || [], auto: !!cfg.auto, folders: mounted });
590
+ await chrome.storage.session.set({ folderTick: Date.now() }).catch(() => {});
591
+ }
592
+
423
593
  function decideSites(jobId, allow) {
424
594
  chrome.notifications?.clear(`hc-cloud-sites:${jobId}`, () => void chrome.runtime.lastError);
425
595
  for (const fn of waiters.get(`sites:${jobId}`) || []) fn(!!allow);
426
596
  }
427
597
 
428
- async function decide(jobId, allow) {
429
- await serial(async () => {
598
+ async function decide(jobId, allow, { auto = false } = {}) {
599
+ const changed = await serial(async () => {
430
600
  const tasks = await getTasks();
431
601
  const t = tasks[jobId];
432
- if (!t || t.state !== 'pending') return;
602
+ if (!t || t.state !== 'pending') return false;
433
603
  t.state = allow ? 'approved' : 'denied';
434
604
  t.lastAt = Date.now();
435
605
  if (!allow) t.why = '';
436
- if (allow && t.kind === 'browse') { t.sites = t.ask; t.ask = []; }
606
+ if (allow && t.kind === 'browse') { t.sites = t.ask || []; t.ask = []; }
437
607
  await setTasks(tasks);
608
+ return true;
438
609
  });
439
610
  chrome.notifications?.clear(`hc-cloud:${jobId}`, () => void chrome.runtime.lastError);
440
- await log({ ev: allow ? 'approved' : 'denied', job: jobId });
441
- await pushLive();
611
+ if (changed) {
612
+ await log({ ev: allow ? 'approved' : 'denied', job: jobId, ...(auto && allow ? { msg: '自动放行' } : {}) });
613
+ await pushLive();
614
+ }
442
615
  for (const fn of waiters.get(jobId) || []) fn(!!allow);
443
616
  }
444
617
 
@@ -456,8 +629,10 @@ async function endTask(jobId, by) {
456
629
  if (!t) return;
457
630
  for (const fn of waiters.get(jobId) || []) fn(false);
458
631
  for (const fn of waiters.get(`sites:${jobId}`) || []) fn(false);
632
+ for (const fn of waiters.get(`folders:${jobId}`) || []) fn(false);
459
633
  chrome.notifications?.clear(`hc-cloud:${jobId}`, () => void chrome.runtime.lastError);
460
634
  chrome.notifications?.clear(`hc-cloud-sites:${jobId}`, () => void chrome.runtime.lastError);
635
+ chrome.notifications?.clear(`hc-cloud-folders:${jobId}`, () => void chrome.runtime.lastError);
461
636
  await closeTaskTabs(t, { force: by === 'user' });
462
637
  await pushLive();
463
638
  await log({ ev: 'ended', job: jobId, by });
@@ -488,11 +663,13 @@ async function handle(m) {
488
663
  try {
489
664
  const data = await exec(m);
490
665
  let out = data;
666
+ const audit = out && typeof out === 'object' ? out.audit : '';
667
+ if (out && typeof out === 'object') delete out.audit;
491
668
  if (JSON.stringify(out ?? null).length > policy.RESULT_MAX) {
492
669
  out = { text: String(data?.text || '').slice(0, 200000) + '\n…(结果太大,已截断)' };
493
670
  }
494
671
  send({ type: 'res', id: m.id, ok: true, data: out });
495
- await log({ job: jobId, cmd: m.cmd, ok: true, ms: Date.now() - started });
672
+ await log({ job: jobId, cmd: m.cmd, ok: true, ms: Date.now() - started, ...(audit ? { msg: String(audit).slice(0, 160) } : {}) });
496
673
  } catch (e) {
497
674
  send({ type: 'res', id: m.id, ok: false, error: { code: e.code || 'INTERNAL', message: e.message || String(e) } });
498
675
  await log({ job: jobId, cmd: m.cmd, ok: false, code: e.code || 'INTERNAL', msg: String(e.message || e).slice(0, 120) });
@@ -520,8 +697,8 @@ async function exec(m) {
520
697
  if (recent.length >= PER_MINUTE) throw err('CLOUD_RATE', '命令太密(每分钟最多 120 条),放慢一点');
521
698
  recent.push(now);
522
699
 
523
- const reason = m.cmd === 'sites' ? String(m.params?.reason || '').slice(0, 200) : '';
524
- const task = await taskFor(m.job, cfg, reason);
700
+ const reason = m.cmd === 'sites' || m.cmd === 'folders' ? String(m.params?.reason || '').slice(0, 200) : '';
701
+ const task = await taskFor(m.job, cfg, reason, { allowEmpty: m.cmd === 'folders' });
525
702
  const jobId = task.jobId;
526
703
  const updated = await updateTask(jobId, (t) => { t.count += 1; t.lastAt = Date.now(); });
527
704
  if (!updated) throw err('CLOUD_DENIED', '这个任务已经结束');
@@ -530,6 +707,8 @@ async function exec(m) {
530
707
  const hosts = hostsOf(updated);
531
708
  const p = policy.checkCommand(m.cmd, m.params, { hosts, origin: cfg.origin });
532
709
  if (m.cmd === 'sites') return requestSites(updated, p, cfg);
710
+ if (m.cmd === 'folders') return requestFolders(updated, p, cfg);
711
+ if (m.cmd === 'fs_list' || m.cmd === 'fs_read') return fsCmd(m.cmd, p, updated);
533
712
  const sid = sidOf(jobId);
534
713
  const label = labelOf(cfg.origin);
535
714
  const own = new Set(updated.tabs);
@@ -704,6 +883,7 @@ async function panelState() {
704
883
  status: status || { state: 'off' },
705
884
  claim: claimed && claimed.expiresAt > Date.now() ? { origin: claimed.origin, account: claimed.account } : null,
706
885
  tasks: Object.values(tasks).sort((a, b) => b.lastAt - a.lastAt),
886
+ folders: await folders.listFolders().catch(() => []),
707
887
  log: list.slice(-40).reverse(),
708
888
  platforms: Object.entries(policy.PLATFORMS).map(([id, p]) => ({ id, name: p.name, hosts: p.hosts })),
709
889
  defaultOrigin: policy.DEFAULT_ORIGIN,
@@ -715,7 +895,8 @@ async function patchCfg(patch) {
715
895
  if (!cfg) throw err('CLOUD_UNPAIRED', '还没有配对');
716
896
  const next = { ...cfg, ...patch };
717
897
  await setCfg(next);
718
- send({ type: 'config', platforms: next.platforms, auto: !!next.auto });
898
+ const mounted = await folders.names().catch(() => []);
899
+ send({ type: 'config', platforms: next.platforms, auto: !!next.auto, folders: mounted });
719
900
  return next;
720
901
  }
721
902
 
@@ -738,7 +919,16 @@ async function onPanel(m) {
738
919
  await log({ ev: 'platforms', platforms: ids.join(',') });
739
920
  return panelState();
740
921
  }
741
- case 'setAuto': await patchCfg({ auto: !!m.auto }); await log({ ev: m.auto ? 'auto_on' : 'auto_off' }); return panelState();
922
+ case 'setAuto': {
923
+ await patchCfg({ auto: !!m.auto });
924
+ await log({ ev: m.auto ? 'auto_on' : 'auto_off' });
925
+ // 已经挂着「允许」的任务跟着放行。追加网站和文件夹不在 pending 上,这里碰不到。
926
+ if (m.auto) {
927
+ const pending = Object.values(await getTasks()).filter((t) => t.state === 'pending');
928
+ for (const t of pending) await decide(t.jobId, true, { auto: true });
929
+ }
930
+ return panelState();
931
+ }
742
932
  case 'setPaused': {
743
933
  await patchCfg({ paused: !!m.paused });
744
934
  await log({ ev: m.paused ? 'paused' : 'resumed' });
@@ -747,6 +937,10 @@ async function onPanel(m) {
747
937
  }
748
938
  case 'decide': await decide(String(m.jobId), !!m.allow); return panelState();
749
939
  case 'decideSites': decideSites(String(m.jobId), !!m.allow); return panelState();
940
+ case 'decideFolders': decideFolders(String(m.jobId), !!m.allow); return panelState();
941
+ case 'openMount': await openMount(m.folderId ? String(m.folderId) : ''); return panelState();
942
+ case 'removeFolder': await folders.removeFolder(String(m.id || '')); await reportFolders(); return panelState();
943
+ case 'foldersChanged': await reportFolders(); return panelState();
750
944
  case 'endTask': await endTask(String(m.jobId), 'user'); return panelState();
751
945
  case 'reconnect': disconnect(); retryDelay = 0; await connect(); return panelState();
752
946
  default: throw err('INTERNAL', `未知操作 ${m.op}`);
@@ -769,9 +963,15 @@ export function initCloud(d) {
769
963
  });
770
964
 
771
965
  chrome.notifications?.onButtonClicked.addListener((id, idx) => {
772
- if (id.startsWith('hc-cloud-sites:')) decideSites(id.slice('hc-cloud-sites:'.length), idx === 0);
966
+ if (id.startsWith('hc-cloud-folders:')) decideFolders(id.slice('hc-cloud-folders:'.length), idx === 0);
967
+ else if (id.startsWith('hc-cloud-sites:')) decideSites(id.slice('hc-cloud-sites:'.length), idx === 0);
773
968
  else if (id.startsWith('hc-cloud:')) void decide(id.slice('hc-cloud:'.length), idx === 0);
969
+ else if (id.startsWith('hc-folder:')) void openMount(id.slice('hc-folder:'.length));
970
+ });
971
+ chrome.notifications?.onClicked.addListener((id) => {
972
+ if (id.startsWith('hc-folder:')) void openMount(id.slice('hc-folder:'.length));
774
973
  });
974
+ chrome.windows?.onRemoved.addListener((id) => { if (id === mountWin) mountWin = null; });
775
975
 
776
976
  // 从本任务的页面里点开的新标签页(「去发布」常在新页打开)归同一个任务,
777
977
  // 白名单照样管它:下一条命令前、结果出门前都会核对地址
@@ -715,7 +715,33 @@
715
715
  const base = { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, button: 0 };
716
716
  const ptr = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true };
717
717
 
718
- const fire = (Ctor, type, extra) => el.dispatchEvent(new Ctor(type, { ...extra }));
718
+ // 点在 <a href="javascript:..."> 上时,click 的默认动作会去执行这个地址。
719
+ // 页面的 CSP(script-src 没有 unsafe-inline)会拦住,并在控制台记
720
+ // 「Running the JavaScript URL」。监听器该跑的已经在派发过程里跑过了,
721
+ // 被拦的只是这段地址本身。这里在默认动作之前 preventDefault,地址不再执行。
722
+ const javascriptLink = (node) => {
723
+ for (let n = node; n; n = n.parentElement || n.parentNode?.host) {
724
+ if (n.nodeType !== 1 || !/^(A|AREA)$/i.test(n.tagName)) continue;
725
+ const href = n.getAttribute('href') || n.getAttribute('xlink:href') || '';
726
+ if (/^\s*javascript:/i.test(href)) return n;
727
+ }
728
+ return null;
729
+ };
730
+ const fire = (Ctor, type, extra) => {
731
+ if (type === 'click') {
732
+ const stop = (ev) => ev.preventDefault();
733
+ const link = javascriptLink(el);
734
+ const form = el.form || el.closest?.('form');
735
+ const blockForm = form && /^\s*javascript:/i.test(form.getAttribute('action') || '');
736
+ if (link) link.addEventListener('click', stop, true);
737
+ if (blockForm) form.addEventListener('submit', stop, true);
738
+ const sent = el.dispatchEvent(new Ctor(type, { ...extra }));
739
+ if (link) link.removeEventListener('click', stop, true);
740
+ if (blockForm) form.removeEventListener('submit', stop, true);
741
+ return sent;
742
+ }
743
+ return el.dispatchEvent(new Ctor(type, { ...extra }));
744
+ };
719
745
  try {
720
746
  fire(PointerEvent, 'pointerover', ptr);
721
747
  fire(MouseEvent, 'mouseover', base);