vcorp-chrome 1.3.1 → 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.
- package/docs/Install_snapshot.png +0 -0
- package/docs/Intro_snapshot.png +0 -0
- package/docs//344/272/221/347/253/257/345/217/221/345/270/203.md +22 -7
- package/extension/cloud-policy.js +144 -4
- package/extension/cloud.js +235 -35
- package/extension/content.js +27 -1
- package/extension/folders.js +247 -0
- package/extension/manifest.json +1 -1
- package/extension/mount.html +61 -0
- package/extension/mount.js +132 -0
- package/extension/net-hook.js +39 -10
- package/extension/panel.html +8 -1
- package/extension/panel.js +48 -2
- package/package.json +1 -1
package/extension/cloud.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
315
|
-
|
|
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:
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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':
|
|
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-
|
|
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
|
// 白名单照样管它:下一条命令前、结果出门前都会核对地址
|
package/extension/content.js
CHANGED
|
@@ -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
|
-
|
|
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);
|