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.
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "vcorp-chrome",
4
- "version": "1.3.1",
4
+ "version": "1.4.1",
5
5
  "description": "让 AI agent 操控你已登录的 Chrome。基于花叔的 huashu-chrome(MIT)修改。",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq6xRqEwG4yTuHRG1Zah3j//T3B72XnazyZ+9fOCa5P4bBLPmNBN7YAGL5WnjtPVJefXT9r2Ol0qvNztayDG3dMjK6AZoWccgKa5rhxcvWgFUjanXtmBBQ0qgYwNhuEJSkdinLLtyUg6YmK7Mn72NWpfa/NfqNHdK5AHslvvtJ50iQAYmabl2Nd3vOJ9DxtLBt8a2DfoGPWizgEB3+gkqm2PB8LywwwoiGKd7acr1wZEWmWK9/s0YWdzIstjlvk5AO5XFn6EtEzW8tAAOwBka9239pcyj0kEogWctHLJn7p9VFru38r+I1USyGn6u9XQ68n9edWddxBYokETCqEMT9QIDAQAB",
7
7
  "minimum_chrome_version": "116",
@@ -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
 
@@ -158,7 +158,7 @@
158
158
  <div class="muted" style="margin-top:4px">只勾你确实要交给它发布的。没勾的平台,它连页面都打不开。</div>
159
159
  <div id="cloudTasks"></div>
160
160
  <label class="sw-row">
161
- <span class="txt">自动允许新任务<div class="sub">不再逐个弹窗确认。只在你信任这个账号、且只勾了要发的平台时打开。不管额外网站:发布任务临时申请的网站,以及操作其它网站的任务,每个网站都要你点头</div></span>
161
+ <span class="txt">自动允许新任务<div class="sub">不再逐个弹窗确认。只在你信任这个账号时打开:新任务,以及它一开始就要去的网站,会直接放行。任务开始之后再加的网站,还有本地文件夹,仍然要你逐个点头</div></span>
162
162
  <input type="checkbox" class="toggle" id="cloudAuto" />
163
163
  </label>
164
164
  <label class="sw-row">
@@ -174,6 +174,13 @@
174
174
  <div class="msg bad" id="cloudErr" hidden></div>
175
175
  </section>
176
176
 
177
+ <section>
178
+ <h2>本地文件夹</h2>
179
+ <div class="muted">挂载之后,配对的网站可以申请读取里面的文档(Markdown、文本、PDF、Word、CSV、JSON、HTML)。只读,不会改你的文件。每次真正读取之前还会再问你一次。浏览器重启后如果这里显示需要恢复,点「恢复访问」。</div>
180
+ <div id="folderList"></div>
181
+ <div class="row"><button class="btn" id="folderAdd" type="button">添加文件夹</button></div>
182
+ </section>
183
+
177
184
  <section>
178
185
  <h2>设置</h2>
179
186
  <a class="sw-row guide" id="guide" href="welcome.html">
@@ -250,10 +250,42 @@ const CLOUD_STATE = {
250
250
  const LOG_TEXT = {
251
251
  paired: '配对', unpaired: '解除配对', approved: '批准任务', denied: '拒绝任务', ended: '任务结束',
252
252
  sites_on: '允许了新网站', sites_off: '拒绝了新网站', sites_timeout: '新网站没回应',
253
+ folders_on: '允许了本地文件夹', folders_off: '拒绝了本地文件夹', folders_timeout: '本地文件夹没回应',
253
254
  platforms: '改了允许的平台', probe: '检查登录', auto_on: '打开自动允许', auto_off: '关闭自动允许', paused: '暂停', resumed: '恢复',
254
255
  };
255
256
 
257
+ const PERM_WORD = { granted: '可读', prompt: '需要恢复访问', denied: '已拒绝', unknown: '打不开' };
258
+
259
+ function renderFolders(s) {
260
+ const box = $('folderList');
261
+ if (!box) return;
262
+ box.textContent = '';
263
+ const rows = s.folders || [];
264
+ if (!rows.length) {
265
+ box.appendChild(el('div', 'empty', '还没有。添加后,任务要读的时候会再问你一次。'));
266
+ return;
267
+ }
268
+ for (const f of rows) {
269
+ const row = el('div', 'sw-row');
270
+ const text = el('span', 'txt');
271
+ text.append(el('div', null, f.name), el('div', 'sub', PERM_WORD[f.permission] || PERM_WORD.unknown));
272
+ row.appendChild(text);
273
+ if (f.permission !== 'granted') {
274
+ const restore = el('button', 'btn', '恢复访问');
275
+ restore.onclick = () => cloudCall('openMount', { folderId: f.id });
276
+ row.appendChild(restore);
277
+ }
278
+ const del = el('button', 'btn ghost', '移除');
279
+ del.onclick = () => {
280
+ if (confirm(`移除「${f.name}」?已经批准的任务也不能再读它。文件夹本身不会被删除。`)) cloudCall('removeFolder', { id: f.id });
281
+ };
282
+ row.appendChild(del);
283
+ box.appendChild(row);
284
+ }
285
+ }
286
+
256
287
  function renderCloud(s) {
288
+ renderFolders(s);
257
289
  const on = !!s.cfg;
258
290
  $('cloudPair').hidden = on || !!s.claim;
259
291
  $('cloudClaim').hidden = on || !s.claim;
@@ -322,6 +354,17 @@ function renderCloud(s) {
322
354
  ask.append(yes, no);
323
355
  box.appendChild(ask);
324
356
  }
357
+ if (t.folders?.length) box.appendChild(el('div', 'sub', `可读本地文件夹:${t.folders.join('、')}`));
358
+ if (t.folderAsk?.length) {
359
+ box.appendChild(el('div', 'sub', `还想读取本地文件夹:${t.folderAsk.join('、')}${t.folderAskWhy ? `。用途:${t.folderAskWhy}` : ''}`));
360
+ const ask = el('div', 'row');
361
+ const yes = el('button', 'btn', '允许这些文件夹');
362
+ const no = el('button', 'btn ghost', '不允许');
363
+ yes.onclick = () => cloudCall('decideFolders', { jobId: t.jobId, allow: true });
364
+ no.onclick = () => cloudCall('decideFolders', { jobId: t.jobId, allow: false });
365
+ ask.append(yes, no);
366
+ box.appendChild(ask);
367
+ }
325
368
  const row = el('div', 'row');
326
369
  const stop = el('button', 'btn ghost', '收回(关掉它的标签页)');
327
370
  stop.onclick = () => cloudCall('endTask', { jobId: t.jobId });
@@ -336,7 +379,9 @@ function renderCloud(s) {
336
379
  if (!s.log.length) log.appendChild(el('div', null, '还没有记录'));
337
380
  for (const r of s.log) {
338
381
  const row = el('div');
339
- const text = r.ev ? `${LOG_TEXT[r.ev] || r.ev}${r.msg ? ` ${r.msg}` : ''}` :`${r.cmd}${r.ok ? '' : ` ✗ ${r.code}${r.msg ? `:${r.msg}` : ''}`}`;
382
+ const text = r.ev === 'approved' && r.msg === '自动放行'
383
+ ? '自动放行了任务'
384
+ : r.ev ? `${LOG_TEXT[r.ev] || r.ev}${r.msg ? ` ${r.msg}` : ''}` : `${r.cmd}${r.ok ? '' : ` ✗ ${r.code}${r.msg ? `:${r.msg}` : ''}`}`;
340
385
  row.append(el('span', 'ago', relTime(r.t)), el('span', r.ok === false ? 'no' : null, `${text}${r.job ? ` · ${String(r.job).slice(-6)}` : ''}`));
341
386
  log.appendChild(row);
342
387
  }
@@ -361,12 +406,13 @@ $('cloudReconn').onclick = () => cloudCall('reconnect');
361
406
  $('cloudUnpair').onclick = () => {
362
407
  if (confirm('断开后这个服务器再也不能在本浏览器里发布,它开的标签页会被关掉。要再用得重新配对。')) cloudCall('unpair');
363
408
  };
409
+ $('folderAdd').onclick = () => cloudCall('openMount');
364
410
 
365
411
  // ---------- 订阅 ----------
366
412
 
367
413
  chrome.storage.onChanged.addListener((changes, area) => {
368
414
  const keys0 = Object.keys(changes);
369
- if (keys0.some((k) => ['cloud', 'cloudTasks', 'cloudLog', 'cloudStatus', 'cloudClaim'].includes(k))) void cloudCall('state');
415
+ if (keys0.some((k) => ['cloud', 'cloudTasks', 'cloudLog', 'cloudStatus', 'cloudClaim', 'folderTick'].includes(k))) void cloudCall('state');
370
416
  if (area !== 'session') return;
371
417
  const keys = Object.keys(changes);
372
418
  if (keys.includes(`panel:${tabId}`) || keys.includes('liveSids')) renderPage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vcorp-chrome",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "让任何 AI agent 操控你自己的 Chrome——带着全部登录态。MCP + Chrome 扩展,Claude Code / Codex / Cursor / Gemini CLI 通用。",
5
5
  "type": "module",
6
6
  "bin": {