vcorp-chrome 1.3.1 → 1.5.0

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.
@@ -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);
@@ -0,0 +1,247 @@
1
+ // 用户挂载的本地文件夹。句柄存在扩展自己的 IndexedDB,和配对密钥不是同一个库。
2
+ //
3
+ // 读文件放在 service worker 里,选文件夹和重新授权放在 mount.html 里。依据:
4
+ // - File System Access 规范把 queryPermission、getFile、目录 entries 暴露给 Worker
5
+ // (Exposed=Window,Worker)。扩展的 service worker 和扩展页面是同一个源,
6
+ // 页面里 showDirectoryPicker 拿到的句柄写进 IndexedDB 之后,SW 读得出来。
7
+ // woxxom 在 StackOverflow 上给的做法也是这条:句柄进 IndexedDB,SW 复用。
8
+ // - showDirectoryPicker 和 requestPermission 都要用户激活,而且 SW 没有 window。
9
+ // 这两件事只能在扩展页面里、由用户点一下来做。
10
+ // - 权限不保证跨过浏览器重启。Chrome 122 起用户可以选「每次访问都允许」,
11
+ // 但 chrome-extension 源不是已安装的 PWA,重启后 queryPermission 经常回到 prompt。
12
+ // 所以每次读之前都问一次;不是 granted 就返回 FOLDER_LOCKED,让用户在窗口里点「恢复访问」。
13
+ // 不把「重启后仍然 granted」当成前提。
14
+ //
15
+ // 这里不认识云端命令。谁能读、读哪种文件、字节往哪送,由 cloud-policy.js 决定。
16
+
17
+ import { cleanFolderName, fileAllowed, skipEntry, matchGlob, FS_FILE_MAX, FS_LIST_MAX, FS_FOLDERS_MAX } from './cloud-policy.js';
18
+
19
+ const DB = 'hc-folders';
20
+
21
+ const fail = (code, message) => Object.assign(new Error(message), { code });
22
+
23
+ function idb() {
24
+ return new Promise((resolve, reject) => {
25
+ const r = indexedDB.open(DB, 1);
26
+ r.onupgradeneeded = () => r.result.createObjectStore('folders', { keyPath: 'id' });
27
+ r.onsuccess = () => resolve(r.result);
28
+ r.onerror = () => reject(r.error);
29
+ });
30
+ }
31
+
32
+ async function idbOp(mode, fn) {
33
+ const db = await idb();
34
+ try {
35
+ return await new Promise((resolve, reject) => {
36
+ const tx = db.transaction('folders', mode);
37
+ const req = fn(tx.objectStore('folders'));
38
+ tx.oncomplete = () => resolve(req?.result);
39
+ tx.onerror = () => reject(tx.error);
40
+ });
41
+ } finally { db.close(); }
42
+ }
43
+
44
+ const all = () => idbOp('readonly', (s) => s.getAll());
45
+ const get = (id) => idbOp('readonly', (s) => s.get(id));
46
+
47
+ function newId() {
48
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
49
+ const bytes = crypto.getRandomValues(new Uint8Array(9));
50
+ return `fld_${[...bytes].map((b) => alphabet[b % alphabet.length]).join('')}`;
51
+ }
52
+
53
+ function mapFsError(e, rel) {
54
+ if (e?.code) return e;
55
+ if (e?.name === 'NotAllowedError' || e?.name === 'SecurityError') {
56
+ return fail('FOLDER_LOCKED', '读取权限失效了,需要你重新点一次允许');
57
+ }
58
+ if (e?.name === 'NotFoundError' || e?.name === 'TypeMismatchError') {
59
+ return fail('NOT_FOUND', rel ? `没有 ${rel}` : '没有这个路径');
60
+ }
61
+ return e;
62
+ }
63
+
64
+ // 给服务器的名单:只有显示名。权限状态是给侧栏的,不上报。
65
+ export async function names() {
66
+ const rows = await all();
67
+ return rows.map((r) => ({ id: r.id, name: r.name }));
68
+ }
69
+
70
+ export async function listFolders() {
71
+ const rows = await all();
72
+ const out = [];
73
+ for (const row of rows.sort((a, b) => a.addedAt - b.addedAt)) {
74
+ let permission = 'unknown';
75
+ try { permission = await row.handle.queryPermission({ mode: 'read' }); } catch { /* 句柄坏了,下面照样列出来让用户移除 */ }
76
+ out.push({ id: row.id, name: row.name, addedAt: row.addedAt, permission });
77
+ }
78
+ return out;
79
+ }
80
+
81
+ export async function getByName(name) {
82
+ const rows = await all();
83
+ return rows.find((r) => r.name === name) || null;
84
+ }
85
+
86
+ export async function addFolder(handle, name) {
87
+ const clean = cleanFolderName(name) || cleanFolderName(handle?.name);
88
+ if (!clean) throw fail('BAD_NAME', '文件夹名字不合法');
89
+ const rows = await all();
90
+ if (rows.length >= FS_FOLDERS_MAX) throw fail('FULL', `最多挂载 ${FS_FOLDERS_MAX} 个文件夹,先移除不用的`);
91
+ if (rows.some((r) => r.name === clean)) throw fail('DUP', '已经有同名文件夹,换一个显示名');
92
+ for (const row of rows) {
93
+ try {
94
+ if (await row.handle.isSameEntry(handle)) throw fail('DUP', `这个文件夹已经挂过了,名字是「${row.name}」`);
95
+ } catch (e) {
96
+ if (e.code === 'DUP') throw e;
97
+ }
98
+ }
99
+ const row = { id: newId(), name: clean, handle, addedAt: Date.now() };
100
+ await idbOp('readwrite', (s) => s.put(row));
101
+ return { id: row.id, name: row.name };
102
+ }
103
+
104
+ export async function removeFolder(id) {
105
+ await idbOp('readwrite', (s) => s.delete(id));
106
+ }
107
+
108
+ // 必须在扩展页面里、由用户点击调用。SW 里调会因为没有用户激活而失败。
109
+ export async function requestAccess(id) {
110
+ const row = await get(id);
111
+ if (!row?.handle) throw fail('NOT_FOUND', '没有这个文件夹');
112
+ return row.handle.requestPermission({ mode: 'read' });
113
+ }
114
+
115
+ async function child(dir, name, kind) {
116
+ try {
117
+ return kind === 'file' ? await dir.getFileHandle(name) : await dir.getDirectoryHandle(name);
118
+ } catch (e) {
119
+ throw mapFsError(e, name);
120
+ }
121
+ }
122
+
123
+ async function resolve(root, rel, kind) {
124
+ const parts = rel ? rel.split('/') : [];
125
+ let cur = root;
126
+ for (let i = 0; i < parts.length; i++) {
127
+ const last = i === parts.length - 1;
128
+ cur = await child(cur, parts[i], last && kind === 'file' ? 'file' : 'dir');
129
+ }
130
+ return cur;
131
+ }
132
+
133
+ async function permissionOf(handle) {
134
+ try { return await handle.queryPermission({ mode: 'read' }); }
135
+ catch (e) { throw mapFsError(e); }
136
+ }
137
+
138
+ export async function assertGranted(handle) {
139
+ if (await permissionOf(handle) !== 'granted') {
140
+ throw fail('FOLDER_LOCKED', '读取权限失效了,需要你重新点一次允许');
141
+ }
142
+ }
143
+
144
+ function sizeText(n) {
145
+ if (n < 1024) return `${n} B`;
146
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
147
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
148
+ }
149
+
150
+ // 列出一个已授权的文件夹。glob 有值时只留匹配的文件,目录用来往下走,不出现在结果里。
151
+ export async function listTree(handle, { path = '', depth = 3, glob = '', folder }) {
152
+ await assertGranted(handle);
153
+ let root;
154
+ try { root = await resolve(handle, path, 'dir'); }
155
+ catch (e) {
156
+ if (e.code === 'NOT_FOUND') throw fail('NOT_FOUND', path ? `没有文件夹 ${path}` : '没有这个文件夹');
157
+ throw e;
158
+ }
159
+ const entries = [];
160
+ let truncated = false;
161
+
162
+ async function walk(dir, rel, levelsLeft) {
163
+ if (truncated) return;
164
+ let handles;
165
+ try { handles = dir.entries(); }
166
+ catch (e) { throw mapFsError(e, rel); }
167
+ const batch = [];
168
+ try {
169
+ for await (const [name, h] of handles) batch.push([name, h]);
170
+ } catch (e) { throw mapFsError(e, rel); }
171
+ batch.sort((a, b) => a[0].localeCompare(b[0], 'zh'));
172
+ for (const [name, h] of batch) {
173
+ if (entries.length >= FS_LIST_MAX) { truncated = true; return; }
174
+ if (skipEntry(name)) continue;
175
+ const childRel = rel ? `${rel}/${name}` : name;
176
+ if (h.kind === 'directory') {
177
+ if (!glob) entries.push({ path: childRel, dir: true });
178
+ if (levelsLeft > 1) await walk(h, childRel, levelsLeft - 1);
179
+ continue;
180
+ }
181
+ if (!fileAllowed(name)) continue;
182
+ if (glob && !matchGlob(childRel, glob)) continue;
183
+ let size = null;
184
+ try {
185
+ const file = await h.getFile();
186
+ size = file.size;
187
+ } catch (e) {
188
+ if (e?.name === 'NotFoundError') continue;
189
+ throw mapFsError(e, childRel);
190
+ }
191
+ entries.push({ path: childRel, size });
192
+ }
193
+ }
194
+
195
+ await walk(root, '', depth);
196
+
197
+ const where = `${folder}${path ? `/${path}` : ''}`;
198
+ const lines = [`${where}${glob ? `(${glob})` : ''}`];
199
+ if (!entries.length) lines.push('(没有符合的文件)');
200
+ for (const ent of entries) lines.push(ent.dir ? `${ent.path}/` : `${ent.path}\t${sizeText(ent.size)}`);
201
+ if (truncated) lines.push(`(已到 ${FS_LIST_MAX} 条上限,后面的没列。缩小 path 或 glob 再列。)`);
202
+ return {
203
+ text: lines.join('\n'),
204
+ truncated,
205
+ audit: `list ${where}${truncated ? ' truncated' : ''} ${entries.length}`,
206
+ };
207
+ }
208
+
209
+ export async function readFile(handle, rel) {
210
+ await assertGranted(handle);
211
+ let file;
212
+ try {
213
+ const h = await resolve(handle, rel, 'file');
214
+ file = await h.getFile();
215
+ } catch (e) {
216
+ throw mapFsError(e, rel);
217
+ }
218
+ const base = rel.split('/').pop();
219
+ if (skipEntry(base) || !fileAllowed(base) || !fileAllowed(file.name)) {
220
+ throw fail('CLOUD_DENIED', `不读取这种文件:${base}`);
221
+ }
222
+ if (file.size > FS_FILE_MAX) throw fail('CLOUD_DENIED', `「${base}」有 ${sizeText(file.size)},超过单文件 32MB 上限`);
223
+ return file;
224
+ }
225
+
226
+ // 把文件字节流到配对服务器的一次性地址。不经过 WebSocket:一条命令的回执上限是 3MB。
227
+ export async function uploadFile(file, url) {
228
+ let res;
229
+ try {
230
+ res = await fetch(url, {
231
+ method: 'PUT',
232
+ credentials: 'omit',
233
+ cache: 'no-store',
234
+ headers: {
235
+ // 不用 file.type。application/json 会被服务器上的 JSON 解析器先吃掉,字节就对不上了。
236
+ 'content-type': 'application/octet-stream',
237
+ 'content-length': String(file.size),
238
+ 'x-file-name': encodeURIComponent(file.name),
239
+ },
240
+ body: file,
241
+ });
242
+ } catch (e) {
243
+ throw fail('INTERNAL', `文件没有传到服务器:${e.message || e}`);
244
+ }
245
+ if (!res.ok) throw fail('INTERNAL', `文件没有传到服务器:${res.status}`);
246
+ return { bytes: file.size, name: file.name };
247
+ }
@@ -1,38 +1,38 @@
1
- {
2
- "manifest_version": 3,
3
- "name": "vcorp-chrome",
4
- "version": "1.3.1",
5
- "description": "让 AI agent 操控你已登录的 Chrome。基于花叔的 huashu-chrome(MIT)修改。",
6
- "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq6xRqEwG4yTuHRG1Zah3j//T3B72XnazyZ+9fOCa5P4bBLPmNBN7YAGL5WnjtPVJefXT9r2Ol0qvNztayDG3dMjK6AZoWccgKa5rhxcvWgFUjanXtmBBQ0qgYwNhuEJSkdinLLtyUg6YmK7Mn72NWpfa/NfqNHdK5AHslvvtJ50iQAYmabl2Nd3vOJ9DxtLBt8a2DfoGPWizgEB3+gkqm2PB8LywwwoiGKd7acr1wZEWmWK9/s0YWdzIstjlvk5AO5XFn6EtEzW8tAAOwBka9239pcyj0kEogWctHLJn7p9VFru38r+I1USyGn6u9XQ68n9edWddxBYokETCqEMT9QIDAQAB",
7
- "minimum_chrome_version": "116",
8
- "background": {
9
- "service_worker": "background.js",
10
- "type": "module"
11
- },
12
- "action": {
13
- "default_title": "vcorp-chrome"
14
- },
15
- "permissions": [
16
- "tabs",
17
- "tabGroups",
18
- "scripting",
19
- "storage",
20
- "alarms",
21
- "offscreen",
22
- "downloads",
23
- "sidePanel",
24
- "notifications",
25
- "debugger"
26
- ],
27
- "host_permissions": [
28
- "<all_urls>"
29
- ],
30
- "icons": {
31
- "16": "icons/icon16.png",
32
- "48": "icons/icon48.png",
33
- "128": "icons/icon128.png"
34
- },
35
- "side_panel": {
36
- "default_path": "panel.html"
37
- }
38
- }
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "vcorp-chrome",
4
+ "version": "1.5.0",
5
+ "description": "让 AI agent 操控你已登录的 Chrome。基于花叔的 huashu-chrome(MIT)修改。",
6
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq6xRqEwG4yTuHRG1Zah3j//T3B72XnazyZ+9fOCa5P4bBLPmNBN7YAGL5WnjtPVJefXT9r2Ol0qvNztayDG3dMjK6AZoWccgKa5rhxcvWgFUjanXtmBBQ0qgYwNhuEJSkdinLLtyUg6YmK7Mn72NWpfa/NfqNHdK5AHslvvtJ50iQAYmabl2Nd3vOJ9DxtLBt8a2DfoGPWizgEB3+gkqm2PB8LywwwoiGKd7acr1wZEWmWK9/s0YWdzIstjlvk5AO5XFn6EtEzW8tAAOwBka9239pcyj0kEogWctHLJn7p9VFru38r+I1USyGn6u9XQ68n9edWddxBYokETCqEMT9QIDAQAB",
7
+ "minimum_chrome_version": "116",
8
+ "background": {
9
+ "service_worker": "background.js",
10
+ "type": "module"
11
+ },
12
+ "action": {
13
+ "default_title": "vcorp-chrome"
14
+ },
15
+ "permissions": [
16
+ "tabs",
17
+ "tabGroups",
18
+ "scripting",
19
+ "storage",
20
+ "alarms",
21
+ "offscreen",
22
+ "downloads",
23
+ "sidePanel",
24
+ "notifications",
25
+ "debugger"
26
+ ],
27
+ "host_permissions": [
28
+ "<all_urls>"
29
+ ],
30
+ "icons": {
31
+ "16": "icons/icon16.png",
32
+ "48": "icons/icon48.png",
33
+ "128": "icons/icon128.png"
34
+ },
35
+ "side_panel": {
36
+ "default_path": "panel.html"
37
+ }
38
+ }
@@ -0,0 +1,61 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <meta charset="utf-8" />
4
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
5
+ <title>挂载本地文件夹</title>
6
+ <style>
7
+ :root {
8
+ color-scheme: light dark;
9
+ --bg: #fafaf9; --card: #ffffff; --ink: #1c1917; --mute: #78716c; --line: rgba(0,0,0,.07);
10
+ --soft: rgba(0,0,0,.04); --ok: #16a34a; --warn: #d97706; --bad: #dc2626; --accent: #2563eb;
11
+ }
12
+ @media (prefers-color-scheme: dark) {
13
+ :root { --bg: #161616; --card: #1f1f1f; --ink: #f2f2f2; --mute: #a1a1aa; --line: rgba(255,255,255,.08);
14
+ --soft: rgba(255,255,255,.05); --ok: #22c55e; --warn: #f59e0b; --bad: #f87171; --accent: #60a5fa; }
15
+ }
16
+ * { box-sizing: border-box; }
17
+ [hidden] { display: none !important; }
18
+ body { margin: 0; background: var(--bg); color: var(--ink);
19
+ font: 13px/1.6 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
20
+ main { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
21
+ h1 { margin: 0; font-size: 15px; }
22
+ p { margin: 0; color: var(--mute); }
23
+ section { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
24
+ button.btn { font: inherit; font-size: 12.5px; border: 0; border-radius: 7px; padding: 6px 12px; cursor: pointer;
25
+ background: var(--accent); color: #fff; }
26
+ button.btn.ghost { background: var(--soft); color: var(--ink); }
27
+ button.btn:disabled { opacity: .45; cursor: default; }
28
+ .row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
29
+ input[type=text] { font: inherit; flex: 1; padding: 6px 8px; border-radius: 7px; border: 1px solid var(--line);
30
+ background: var(--bg); color: var(--ink); }
31
+ .item { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--line); }
32
+ .item:first-child { border-top: 0; }
33
+ .item .name { font-weight: 600; flex: 1; word-break: break-all; }
34
+ .item .sub { color: var(--mute); font-size: 11.5px; }
35
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: #a8a29e; flex: none; }
36
+ .dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); }
37
+ .msg { color: var(--bad); font-size: 12.5px; }
38
+ .empty { color: var(--mute); }
39
+ </style>
40
+ <main>
41
+ <div>
42
+ <h1>挂载本地文件夹</h1>
43
+ <p>选一个文件夹之后,配对的网站可以申请读取里面的文档。每次申请你都会在通知里看到文件夹的名字。只读,不会改你的文件。</p>
44
+ </div>
45
+ <section>
46
+ <button class="btn" id="pick" type="button">选择文件夹</button>
47
+ <div id="naming" hidden>
48
+ <p style="margin-top:8px">给它一个显示名。网站和任务看到的是这个名字,不是磁盘路径。</p>
49
+ <div class="row">
50
+ <input type="text" id="name" maxlength="40" autocomplete="off" spellcheck="false" />
51
+ <button class="btn" id="save" type="button">挂载</button>
52
+ <button class="btn ghost" id="cancel" type="button">取消</button>
53
+ </div>
54
+ </div>
55
+ </section>
56
+ <section>
57
+ <div id="list"><div class="empty">读取中…</div></div>
58
+ </section>
59
+ <p class="msg" id="err" hidden></p>
60
+ </main>
61
+ <script type="module" src="mount.js"></script>
@@ -0,0 +1,132 @@
1
+ // 选文件夹和恢复权限只发生在这个窗口里:这两件事都要用户点一下。
2
+ // 句柄写进 IndexedDB 之后,service worker 才能在没有这个窗口的情况下读文件。
3
+ // 浏览器重启后权限可能回到 prompt,那时「恢复访问」再调一次 requestPermission。
4
+
5
+ import { listFolders, addFolder, removeFolder, requestAccess } from './folders.js';
6
+
7
+ const $ = (id) => document.getElementById(id);
8
+ const focusId = new URLSearchParams(location.search).get('id');
9
+ let pending = null;
10
+
11
+ const showErr = (text) => {
12
+ $('err').hidden = !text;
13
+ $('err').textContent = text || '';
14
+ };
15
+
16
+ const permWord = {
17
+ granted: ['ok', '可读'],
18
+ prompt: ['warn', '需要恢复访问'],
19
+ denied: ['bad', '已拒绝'],
20
+ unknown: ['bad', '打不开'],
21
+ };
22
+
23
+ async function tell() {
24
+ await chrome.runtime.sendMessage({ __hcCloud: 'foldersChanged' }).catch(() => {});
25
+ }
26
+
27
+ async function render() {
28
+ const rows = await listFolders();
29
+ const box = $('list');
30
+ box.textContent = '';
31
+ if (!rows.length) {
32
+ const empty = document.createElement('div');
33
+ empty.className = 'empty';
34
+ empty.textContent = '还没有挂载的文件夹。';
35
+ box.appendChild(empty);
36
+ return;
37
+ }
38
+ for (const row of rows) {
39
+ const [cls, word] = permWord[row.permission] || permWord.unknown;
40
+ const item = document.createElement('div');
41
+ item.className = 'item';
42
+ if (row.id === focusId) item.style.outline = '1px solid var(--warn)';
43
+ const dot = document.createElement('span');
44
+ dot.className = `dot ${cls}`;
45
+ const name = document.createElement('div');
46
+ name.className = 'name';
47
+ name.textContent = row.name;
48
+ const sub = document.createElement('div');
49
+ sub.className = 'sub';
50
+ sub.textContent = word;
51
+ const text = document.createElement('div');
52
+ text.append(name, sub);
53
+ text.style.flex = '1';
54
+ item.append(dot, text);
55
+ if (row.permission !== 'granted') {
56
+ const restore = document.createElement('button');
57
+ restore.className = 'btn';
58
+ restore.type = 'button';
59
+ restore.textContent = '恢复访问';
60
+ restore.onclick = async () => {
61
+ restore.disabled = true;
62
+ showErr('');
63
+ try {
64
+ const state = await requestAccess(row.id);
65
+ if (state !== 'granted') showErr(state === 'denied' ? '没有允许,这个文件夹读不了。' : '还没有允许。');
66
+ } catch (e) {
67
+ showErr(e.message || String(e));
68
+ }
69
+ restore.disabled = false;
70
+ await render();
71
+ };
72
+ item.appendChild(restore);
73
+ }
74
+ const del = document.createElement('button');
75
+ del.className = 'btn ghost';
76
+ del.type = 'button';
77
+ del.textContent = '移除';
78
+ del.onclick = async () => {
79
+ if (!confirm(`移除「${row.name}」?已经批准的任务也不能再读它。文件夹本身不会被删除。`)) return;
80
+ await removeFolder(row.id);
81
+ await tell();
82
+ await render();
83
+ };
84
+ item.appendChild(del);
85
+ box.appendChild(item);
86
+ }
87
+ }
88
+
89
+ function showNaming(on) {
90
+ $('naming').hidden = !on;
91
+ $('pick').disabled = on;
92
+ if (!on) pending = null;
93
+ }
94
+
95
+ $('pick').onclick = async () => {
96
+ showErr('');
97
+ try {
98
+ pending = await showDirectoryPicker({ mode: 'read', id: 'vcorp-local-folder' });
99
+ } catch (e) {
100
+ if (e?.name === 'AbortError') return;
101
+ // 系统目录(桌面、文档、下载)在有的系统上会直接拒绝,换一个普通文件夹。
102
+ showErr(e?.name === 'SecurityError' || e?.name === 'AbortError'
103
+ ? '这个文件夹选不了。换一个普通文件夹,不要选桌面、文档或下载目录本身。'
104
+ : (e.message || String(e)));
105
+ return;
106
+ }
107
+ $('name').value = pending.name.slice(0, 40);
108
+ showNaming(true);
109
+ $('name').focus();
110
+ $('name').select();
111
+ };
112
+
113
+ $('cancel').onclick = () => showNaming(false);
114
+
115
+ $('save').onclick = async () => {
116
+ if (!pending) return;
117
+ $('save').disabled = true;
118
+ showErr('');
119
+ try {
120
+ await addFolder(pending, $('name').value);
121
+ showNaming(false);
122
+ await tell();
123
+ await render();
124
+ } catch (e) {
125
+ showErr(e.message || String(e));
126
+ }
127
+ $('save').disabled = false;
128
+ };
129
+
130
+ $('name').onkeydown = (e) => { if (e.key === 'Enter') $('save').click(); };
131
+
132
+ render().catch((e) => showErr(e.message || String(e)));
@@ -11,23 +11,52 @@
11
11
  const MAX_ENTRIES = 300;
12
12
  const MAX_BODY = 400000;
13
13
 
14
+ // 页面冻结(freeze)期间,浏览器只放行 keepalive:true 的 fetch,其它调用会同步抛
15
+ // 「Only fetch keepalive is allowed during onfreeze」,并把调用栈算在真正执行
16
+ // fetch 的那一层。这里若是 async 包装再转调,控制台就指到本文件。
17
+ // GitHub 的 RUM 会在冻结时用 fetch 把积压发出去,体积一大就不带 keepalive。
18
+ // 那种请求浏览器反正会拒,这里直接拒绝、不再调用原生 fetch,控制台就不会记那一行。
19
+ // keepalive 的则原样同步转交,不读响应体——冻结回调里多做一次 body 读取也会被拒。
20
+ let pageFrozen = false;
21
+ window.addEventListener('freeze', () => { pageFrozen = true; }, true);
22
+ window.addEventListener('resume', () => { pageFrozen = false; }, true);
23
+
14
24
  const push = (r) => {
15
25
  window.__hcNet.push(r);
16
26
  if (window.__hcNet.length > MAX_ENTRIES) window.__hcNet.shift();
17
27
  };
18
28
 
29
+ const keepaliveOf = (args) => {
30
+ const init = args[1];
31
+ if (init && typeof init === 'object' && init.keepalive === true) return true;
32
+ const input = args[0];
33
+ return typeof Request !== 'undefined' && input instanceof Request && input.keepalive === true;
34
+ };
35
+
19
36
  const origFetch = window.fetch;
20
37
  if (origFetch) {
21
- window.fetch = async function (...a) {
22
- const res = await origFetch.apply(this, a);
23
- try {
24
- const url = typeof a[0] === 'string' ? a[0] : a[0]?.url;
25
- const ct = res.headers.get('content-type') || '';
26
- // clone() 是必须的:响应体只能被读一次,直接读会把页面自己的那份吃掉
27
- const body = /json|text|javascript/.test(ct) ? (await res.clone().text()).slice(0, MAX_BODY) : '';
28
- push({ t: Date.now(), method: a[1]?.method || 'GET', url, status: res.status, ct, body });
29
- } catch { /* 记录失败绝不能影响页面本身 */ }
30
- return res;
38
+ window.fetch = function (...args) {
39
+ if (pageFrozen && !keepaliveOf(args)) return Promise.reject(new TypeError('Failed to fetch'));
40
+ let pending;
41
+ try { pending = origFetch.apply(this, args); }
42
+ catch (e) { return Promise.reject(e); }
43
+ if (pageFrozen) return pending;
44
+ // 不 await:页面拿到响应的时机必须和没挂钩之前一样。记录在旁边读 clone。
45
+ Promise.resolve(pending).then((res) => {
46
+ try {
47
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
48
+ const ct = res.headers.get('content-type') || '';
49
+ if (!/json|text|javascript/.test(ct)) {
50
+ push({ t: Date.now(), method: args[1]?.method || 'GET', url, status: res.status, ct, body: '' });
51
+ return;
52
+ }
53
+ // clone() 是必须的:响应体只能被读一次,直接读会把页面自己的那份吃掉
54
+ res.clone().text().then((text) => {
55
+ push({ t: Date.now(), method: args[1]?.method || 'GET', url, status: res.status, ct, body: text.slice(0, MAX_BODY) });
56
+ }).catch(() => {});
57
+ } catch { /* 记录失败绝不能影响页面本身 */ }
58
+ }).catch(() => {});
59
+ return pending;
31
60
  };
32
61
  }
33
62