vcorp-chrome 2.0.0 → 2.2.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.
@@ -17,11 +17,12 @@
17
17
  [hidden] { display: none !important; }
18
18
  body { margin: 0; background: var(--bg); color: var(--ink);
19
19
  font: 13px/1.6 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
20
- header { display: flex; align-items: center; gap: 10px; padding: 14px 16px 10px; }
21
- header img { width: 30px; height: 30px; border-radius: 50%; }
22
- header .t { font-weight: 600; font-size: 14px; }
23
- header .v { color: var(--mute); font-size: 11px; margin-left: 4px; font-weight: 400; }
24
- .pill { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--mute);
20
+ header { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 8px;
21
+ padding: 10px 12px 8px; background: var(--bg); }
22
+ header .jump { font-size: 12px; text-decoration: none; }
23
+ header .jump:hover { text-decoration: underline; }
24
+ header .jump.bad { color: var(--bad); font-weight: 500; }
25
+ .pill { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--mute);
25
26
  padding: 3px 9px; border-radius: 999px; background: var(--soft); }
26
27
  .dot { width: 8px; height: 8px; border-radius: 50%; background: #a8a29e; flex: none; }
27
28
  .dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); }
@@ -101,15 +102,52 @@
101
102
  a.guide { display: flex; text-decoration: none; color: inherit; cursor: pointer; border-radius: 8px; margin: 0 -6px; padding-left: 6px; padding-right: 6px; }
102
103
  a.guide:hover { background: var(--soft); }
103
104
  a.guide .go { color: var(--accent); font-size: 12px; flex: none; }
105
+ /* 需要你处理:置顶、闪烁,处理完就收起 */
106
+ .need { margin-left: auto; all: unset; cursor: pointer; font-size: 11.5px; font-weight: 600; color: #fff; background: var(--warn);
107
+ padding: 3px 9px; border-radius: 999px; animation: blink 1.2s ease-in-out infinite; }
108
+ .alert { border: 1.5px solid var(--warn); background: color-mix(in srgb, var(--warn) 8%, var(--card));
109
+ animation: flash 1.2s ease-in-out infinite; }
110
+ .alert h2 { color: var(--warn); font-weight: 600; display: flex; align-items: center; gap: 6px; }
111
+ .alert .pulse { background: var(--warn); }
112
+ .alert .task:first-child, .alert #cloudClaim:not([hidden]) + #asks .task:first-child { margin-top: 0; }
113
+ .alert .confirm { margin-top: 0; }
114
+ @keyframes flash { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 50%, transparent); }
115
+ 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--warn) 22%, transparent); } }
116
+ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
117
+ @media (prefers-reduced-motion: reduce) { .alert, .need, .pulse { animation: none; } }
118
+ .top-err { margin: 0; padding: 8px 12px; border-radius: 10px; background: color-mix(in srgb, var(--bad) 10%, var(--card)); }
119
+ /* 帮助:全部折叠在最下面 */
120
+ .help details { border-top: 1px solid var(--line); padding: 2px 0 4px; }
121
+ .help details:first-of-type { border-top: 0; }
122
+ .help details summary { color: var(--ink); font-size: 12.5px; margin: 4px 0; }
123
+ .help details > div { color: var(--mute); font-size: 12px; margin: 2px 0 6px; }
124
+ .help code { flex: none; }
104
125
  </style>
105
126
 
127
+ <!-- 侧栏自带标题栏已经写了名字,这里只留状态 -->
106
128
  <header>
107
- <img src="icons/icon128.png" alt="" />
108
- <div class="t">vcorp-chrome<span class="v" id="ver"></span></div>
109
129
  <div class="pill"><span class="dot" id="pilldot"></span><span id="pilltxt">检查中…</span></div>
130
+ <a class="jump" id="connJump" href="#conn">连接详情</a>
131
+ <button class="need" id="needPill" type="button" hidden></button>
110
132
  </header>
111
133
 
112
134
  <main>
135
+ <div class="msg bad top-err" id="cloudErr" hidden></div>
136
+
137
+ <!-- 1. 要你马上点头的:配对确认、任务批准、追加的网站/文件夹/应用/桌面动作 -->
138
+ <section id="alert" class="alert" hidden>
139
+ <h2><span class="pulse"></span>需要你处理</h2>
140
+ <div id="cloudClaim" hidden>
141
+ <div class="confirm">
142
+ <div>要把这个浏览器交给 <b id="claimOrigin"></b> 上的账号 <b id="claimAccount"></b> 吗?</div>
143
+ <div class="muted" style="margin-top:4px">配对码如果是别人发给你的,点取消。</div>
144
+ <div class="row"><button class="btn" id="claimYes">确认连接</button><button class="btn ghost" id="claimNo">取消</button></div>
145
+ </div>
146
+ </div>
147
+ <div id="asks"></div>
148
+ </section>
149
+
150
+ <!-- 2. 正在发生的 -->
113
151
  <section>
114
152
  <h2>这一页</h2>
115
153
  <div id="page"><div class="empty">读取中…</div></div>
@@ -120,56 +158,39 @@
120
158
  <div class="sess" id="sess"></div>
121
159
  </section>
122
160
 
123
- <section>
124
- <h2>连接</h2>
125
- <div id="lights"></div>
126
- <div class="cmd" id="reconnRow" hidden><button class="btn" id="reconn">重连</button>
127
- <span class="sub" style="color:var(--mute);font-size:12px">插件没消失,断的只是到终端的连接</span></div>
128
- </section>
129
-
161
+ <!-- 3. 云端 -->
130
162
  <section id="cloud">
131
163
  <h2>云端发布</h2>
132
164
  <!-- 没配对 -->
133
165
  <div id="cloudPair" hidden>
134
- <div class="muted">让 maker.vcorp.ai 在这个浏览器里替你发布作品、查资料、办事——用的是你已经登录好的账号,
135
- 网站的 cookie 不会离开这台电脑。到网页上「浏览器扩展」里拿一个配对码填在这里。</div>
166
+ <div class="muted">到 maker.vcorp.ai「浏览器扩展」里拿一个配对码填在这里。</div>
136
167
  <div class="field">
137
168
  <input type="text" class="code" id="pairCode" maxlength="9" placeholder="ABCD-EFGH" autocomplete="off" spellcheck="false" />
138
169
  </div>
139
170
  <details>
140
- <summary>服务器地址</summary>
171
+ <summary>服务器地址(自建服务才需要改)</summary>
141
172
  <div class="field"><input type="text" id="pairOrigin" spellcheck="false" /></div>
142
- <div class="muted">只有自建了服务才需要改。</div>
143
173
  </details>
144
174
  <div class="row"><button class="btn" id="pairGo">下一步</button></div>
145
175
  </div>
146
- <!-- 配对第一步完成,等用户确认账号 -->
147
- <div id="cloudClaim" hidden>
148
- <div class="confirm">
149
- <div>要把这个浏览器交给 <b id="claimOrigin"></b> 上的账号 <b id="claimAccount"></b> 吗?</div>
150
- <div class="muted" style="margin-top:4px">确认后,这个账号可以申请在你允许的网站上替你操作,每个任务都要你点头。配对码如果是别人发给你的,点取消。</div>
151
- <div class="row"><button class="btn" id="claimYes">确认连接</button><button class="btn ghost" id="claimNo">取消</button></div>
152
- </div>
153
- </div>
154
176
  <!-- 已配对 -->
155
177
  <div id="cloudOn" hidden>
156
178
  <div class="light"><span class="dot" id="cloudDot"></span>
157
179
  <div><div class="name" id="cloudName"></div><div class="sub" id="cloudSub"></div></div></div>
158
- <div class="lab" style="margin-top:6px">允许它使用的网站</div>
159
- <div class="sites" id="cloudSites"></div>
160
- <div class="muted" style="margin-top:4px">任务申请网站、你点了允许,就记在这里(含子域)。不在这里、也没在任务里批准的网站,它连页面都打不开。</div>
161
180
  <div id="cloudTasks"></div>
181
+ <div class="lab" style="margin-top:10px">允许它使用的网站</div>
182
+ <div class="sites" id="cloudSites"></div>
162
183
  <label class="sw-row">
163
- <span class="txt">自动允许新任务<div class="sub">不再逐个弹窗确认。只在你信任这个账号时打开:新任务,以及它一开始就要去的网站,会直接放行。任务开始之后再加的网站,还有本地文件夹,仍然要你逐个点头</div></span>
164
- <input type="checkbox" class="toggle" id="cloudAuto" />
184
+ <span class="txt">暂停<div class="sub">云端的命令一条都不接,直到你再打开</div></span>
185
+ <input type="checkbox" class="toggle" id="cloudPause" />
165
186
  </label>
166
187
  <label class="sw-row">
167
- <span class="txt">对外动作不再逐条确认<div class="sub">发消息、投递、发帖之前不再问你。扫码、验证码仍然要你本人来。付款一律不做</div></span>
168
- <input type="checkbox" class="toggle" id="cloudAutoConfirm" />
188
+ <span class="txt">自动允许新任务<div class="sub">只在你信任这个账号时打开</div></span>
189
+ <input type="checkbox" class="toggle" id="cloudAuto" />
169
190
  </label>
170
191
  <label class="sw-row">
171
- <span class="txt">暂停<div class="sub">立刻断开,云端的命令一条都不接,直到你再打开</div></span>
172
- <input type="checkbox" class="toggle" id="cloudPause" />
192
+ <span class="txt">对外动作不再逐条确认<div class="sub">发消息、发帖前不再问你;付款一律不做</div></span>
193
+ <input type="checkbox" class="toggle" id="cloudAutoConfirm" />
173
194
  </label>
174
195
  <details>
175
196
  <summary>操作记录</summary>
@@ -177,19 +198,17 @@
177
198
  </details>
178
199
  <div class="row"><button class="btn ghost" id="cloudReconn">重连</button><button class="btn ghost" id="cloudUnpair">断开并忘记这个服务器</button></div>
179
200
  </div>
180
- <div class="msg bad" id="cloudErr" hidden></div>
181
201
  </section>
182
202
 
203
+ <!-- 4. 设置 -->
183
204
  <section>
184
205
  <h2>本地文件夹</h2>
185
- <div class="muted">挂载之后,配对的网站可以申请读取里面的文档(Markdown、文本、PDF、Word、CSV、JSON、HTML)。只读,不会改你的文件。每次真正读取之前还会再问你一次。浏览器重启后如果这里显示需要恢复,点「恢复访问」。</div>
186
206
  <div id="folderList"></div>
187
207
  <div class="row"><button class="btn" id="folderAdd" type="button">添加文件夹</button></div>
188
208
  </section>
189
209
 
190
210
  <section>
191
211
  <h2>桌面应用</h2>
192
- <div class="muted">启用后,配对的网站可以申请操作这台电脑上的某个应用(比如微信、Excel)。每个任务要用的应用都会先问你。浏览器窗口、密码管理器、终端一律不碰;付款不做,发送和删除之前会问你。需要先在终端运行一次 <code>npx vcorp-chrome desktop install</code>。</div>
193
212
  <div id="deskState" class="sub"></div>
194
213
  <div class="row">
195
214
  <button class="btn" id="deskOn" type="button">启用桌面能力</button>
@@ -199,22 +218,62 @@
199
218
 
200
219
  <section>
201
220
  <h2>设置</h2>
202
- <a class="sw-row guide" id="guide" href="welcome.html">
203
- <span class="txt">开始使用<div class="sub">配对码、接上本机 agent、第一句怎么说</div></span>
204
- <span class="go">打开</span>
205
- </a>
206
221
  <label class="sw-row">
207
- <span class="txt">高保真模式<div class="sub">需要时用浏览器级真实输入,应付严格的站点和富文本编辑器;用完自动断开</div></span>
222
+ <span class="txt">高保真模式<div class="sub">需要时用浏览器级真实输入;用完自动断开</div></span>
208
223
  <input type="checkbox" class="toggle" id="l2" />
209
224
  </label>
210
225
  <label class="sw-row">
211
- <span class="txt">页面标记<div class="sub">被操作的页面带彩色描边、光标和小头像;录屏演示时可以关掉</div></span>
226
+ <span class="txt">页面标记<div class="sub">被操作的页面带彩色描边和小头像;录屏时可以关掉</div></span>
212
227
  <input type="checkbox" class="toggle" id="mark" />
213
228
  </label>
214
229
  </section>
215
- </main>
216
230
 
217
- <footer>连不上时在终端跑 <code style="padding:1px 5px">npx vcorp-chrome doctor</code>,它会告诉你卡在哪一步。</footer>
231
+ <!-- 5. 帮助:连接诊断 + 折叠说明,都在最下面;顶上状态旁的「连接详情」跳到这里 -->
232
+ <section id="conn">
233
+ <h2>连接</h2>
234
+ <div id="lights"></div>
235
+ <div class="cmd" id="reconnRow" hidden><button class="btn" id="reconn">重连</button>
236
+ <span class="sub" style="color:var(--mute);font-size:12px">插件没消失,断的只是到终端的连接</span></div>
237
+ </section>
238
+
239
+ <section class="help">
240
+ <h2>帮助</h2>
241
+ <a class="sw-row guide" id="guide" href="welcome.html">
242
+ <span class="txt">开始使用<div class="sub">配对码、接上本机 agent、第一句怎么说</div></span>
243
+ <span class="go">打开</span>
244
+ </a>
245
+ <details>
246
+ <summary>连不上怎么办</summary>
247
+ <div>在终端跑 <code>npx vcorp-chrome doctor</code>,它会告诉你卡在哪一步。</div>
248
+ </details>
249
+ <details>
250
+ <summary>云端发布是什么</summary>
251
+ <div>让 maker.vcorp.ai 在这个浏览器里替你发布作品、查资料、办事——用的是你已经登录好的账号,网站的 cookie 不会离开这台电脑。
252
+ 配对后,这个账号可以申请在你允许的网站上替你操作,每个任务都要你点头。</div>
253
+ </details>
254
+ <details>
255
+ <summary>允许的网站</summary>
256
+ <div>任务申请网站、你点了允许,就记在名单里(含子域)。不在名单里、也没在任务里批准的网站,它连页面都打不开。没有预设的平台。</div>
257
+ </details>
258
+ <details>
259
+ <summary>自动允许和免确认</summary>
260
+ <div>「自动允许新任务」:新任务,以及它一开始就要去的网站,会直接放行。任务开始之后再加的网站,还有本地文件夹,仍然要你逐个点头。</div>
261
+ <div>「对外动作不再逐条确认」:发消息、投递、发帖之前不再问你。扫码、验证码仍然要你本人来。付款一律不做。</div>
262
+ </details>
263
+ <details>
264
+ <summary>本地文件夹</summary>
265
+ <div>挂载之后,配对的网站可以申请读取里面的文档(Markdown、文本、PDF、Word、CSV、JSON、HTML)。只读,不会改你的文件。每次真正读取之前还会再问你一次。浏览器重启后如果显示需要恢复,点「恢复访问」。</div>
266
+ </details>
267
+ <details>
268
+ <summary>桌面应用</summary>
269
+ <div>启用后,配对的网站可以申请操作这台电脑上的某个应用(比如微信、Excel)。每个任务要用的应用都会先问你。浏览器窗口、密码管理器、终端一律不碰;付款不做,发送和删除之前会问你。需要先在终端运行一次 <code>npx vcorp-chrome desktop install</code>。</div>
270
+ </details>
271
+ <details>
272
+ <summary>高保真模式</summary>
273
+ <div>需要时用浏览器级真实输入,应付严格的站点和富文本编辑器;用完自动断开。</div>
274
+ </details>
275
+ </section>
276
+ </main>
218
277
 
219
278
  <script type="module" src="panel.js"></script>
220
279
  </html>
@@ -21,7 +21,10 @@ const send = (msg) => chrome.runtime.sendMessage(msg).catch(() => null);
21
21
  const MINE = chrome.runtime.getManifest().version;
22
22
  const ACT_MS = 5000; // 「正在做」只亮 5 秒——停手了还亮着就是在骗人
23
23
 
24
- $('ver').textContent = `v${MINE}`;
24
+ $('connJump').onclick = (e) => {
25
+ e.preventDefault();
26
+ $('conn').scrollIntoView({ behavior: 'smooth', block: 'start' });
27
+ };
25
28
 
26
29
  const relTime = (t) => {
27
30
  const s = Math.max(0, Math.round((Date.now() - t) / 1000));
@@ -189,6 +192,9 @@ async function renderConn(sessionCount) {
189
192
 
190
193
  $('pilldot').className = `dot ${pill[0]}`;
191
194
  $('pilltxt').textContent = pill[1];
195
+ // 断了或有更新时,跳转链接改成「去修复」,修法在下面的连接区
196
+ $('connJump').textContent = pill[0] === 'ok' ? '连接详情' : '去修复';
197
+ $('connJump').className = `jump${pill[0] === 'bad' ? ' bad' : ''}`;
192
198
  }
193
199
 
194
200
  async function refreshAll() {
@@ -302,9 +308,35 @@ function renderDesktop(s) {
302
308
  : d.error || '未启用';
303
309
  }
304
310
 
311
+ // 一条待批准的申请,放进顶上的「需要你处理」
312
+ function askBox(title, lines, yesText, noText, op, jobId) {
313
+ const box = el('div', 'task pending');
314
+ box.appendChild(el('div', 't', title));
315
+ for (const line of lines) box.appendChild(el('div', 'sub', line));
316
+ const row = el('div', 'row');
317
+ const yes = el('button', 'btn', yesText);
318
+ const no = el('button', 'btn ghost', noText);
319
+ yes.onclick = () => cloudCall(op, { jobId, allow: true });
320
+ no.onclick = () => cloudCall(op, { jobId, allow: false });
321
+ row.append(yes, no);
322
+ box.appendChild(row);
323
+ $('asks').appendChild(box);
324
+ }
325
+
326
+ // 有事要处理:顶上区块和标题栏的闪烁徽标一起亮,处理完一起收
327
+ function syncAlert() {
328
+ const n = $('asks').childElementCount + ($('cloudClaim').hidden ? 0 : 1);
329
+ $('alert').hidden = !n;
330
+ $('needPill').hidden = !n;
331
+ $('needPill').textContent = `${n} 项待处理`;
332
+ document.title = n ? `(${n}) vcorp-chrome` : 'vcorp-chrome';
333
+ }
334
+ $('needPill').onclick = () => $('alert').scrollIntoView({ behavior: 'smooth', block: 'start' });
335
+
305
336
  function renderCloud(s) {
306
337
  renderFolders(s);
307
338
  renderDesktop(s);
339
+ $('asks').textContent = '';
308
340
  const on = !!s.cfg;
309
341
  $('cloudPair').hidden = on || !!s.claim;
310
342
  $('cloudClaim').hidden = on || !s.claim;
@@ -314,7 +346,7 @@ function renderCloud(s) {
314
346
  $('claimOrigin').textContent = new URL(s.claim.origin).host;
315
347
  $('claimAccount').textContent = `「${s.claim.account || '(未命名)'}」`;
316
348
  }
317
- if (!on) return;
349
+ if (!on) { syncAlert(); return; }
318
350
 
319
351
  const [cls, word] = CLOUD_STATE[s.status?.state] || CLOUD_STATE.off;
320
352
  $('cloudDot').className = `dot ${cls}`;
@@ -338,77 +370,38 @@ function renderCloud(s) {
338
370
  sites.appendChild(row);
339
371
  }
340
372
 
373
+ // 要用户点头的一律进顶上的「需要你处理」,这里只留进行中任务的状态
341
374
  const tasks = $('cloudTasks');
342
375
  tasks.textContent = '';
343
376
  for (const t of s.tasks.filter((x) => x.state !== 'denied')) {
344
- const box = el('div', `task ${t.state}`);
345
- box.appendChild(el('div', 't', t.title || `任务 ${t.jobId}`));
377
+ const title = t.title || `任务 ${t.jobId}`;
346
378
  if (t.state === 'pending') {
347
379
  const lines = [];
348
380
  if (t.ask?.length) lines.push(`请求访问这些网站:${t.ask.join('、')}(允许后加入允许名单)`);
349
381
  if (t.use?.length) lines.push(`要用允许名单里的:${t.use.join('、')}`);
350
382
  if (t.purpose) lines.push(`用途:${t.purpose}`);
351
- for (const line of lines) box.appendChild(el('div', 'sub', line));
352
- const row = el('div', 'row');
353
- const yes = el('button', 'btn', '允许');
354
- const no = el('button', 'btn ghost', '拒绝');
355
- yes.onclick = () => cloudCall('decide', { jobId: t.jobId, allow: true });
356
- no.onclick = () => cloudCall('decide', { jobId: t.jobId, allow: false });
357
- row.append(yes, no);
358
- box.appendChild(row);
359
- } else {
360
- const where = [...new Set([...(t.use || []), ...(t.sites || [])])].join('、') || '(还没有网站)';
361
- box.appendChild(el('div', 'sub', `进行中 · ${where} · ${t.count} 条命令 · ${relTime(t.lastAt)}`));
362
- if (t.ask?.length) {
363
- box.appendChild(el('div', 'sub', `还想访问:${t.ask.join('、')}${t.askWhy ? `。用途:${t.askWhy}` : ''}`));
364
- const ask = el('div', 'row');
365
- const yes = el('button', 'btn', '允许这些网站');
366
- const no = el('button', 'btn ghost', '不允许');
367
- yes.onclick = () => cloudCall('decideSites', { jobId: t.jobId, allow: true });
368
- no.onclick = () => cloudCall('decideSites', { jobId: t.jobId, allow: false });
369
- ask.append(yes, no);
370
- box.appendChild(ask);
371
- }
372
- if (t.folders?.length) box.appendChild(el('div', 'sub', `可读本地文件夹:${t.folders.join('、')}`));
373
- if (t.folderAsk?.length) {
374
- box.appendChild(el('div', 'sub', `还想读取本地文件夹:${t.folderAsk.join('、')}${t.folderAskWhy ? `。用途:${t.folderAskWhy}` : ''}`));
375
- const ask = el('div', 'row');
376
- const yes = el('button', 'btn', '允许这些文件夹');
377
- const no = el('button', 'btn ghost', '不允许');
378
- yes.onclick = () => cloudCall('decideFolders', { jobId: t.jobId, allow: true });
379
- no.onclick = () => cloudCall('decideFolders', { jobId: t.jobId, allow: false });
380
- ask.append(yes, no);
381
- box.appendChild(ask);
382
- }
383
- if (t.apps?.length) box.appendChild(el('div', 'sub', `可操作桌面应用:${t.apps.join('、')}`));
384
- if (t.appAsk?.length) {
385
- box.appendChild(el('div', 'sub', `还想操作桌面应用:${t.appAsk.join('、')}${t.appAskWhy ? `。用途:${t.appAskWhy}` : ''}`));
386
- const ask = el('div', 'row');
387
- const yes = el('button', 'btn', '允许这些应用');
388
- const no = el('button', 'btn ghost', '不允许');
389
- yes.onclick = () => cloudCall('decideApps', { jobId: t.jobId, allow: true });
390
- no.onclick = () => cloudCall('decideApps', { jobId: t.jobId, allow: false });
391
- ask.append(yes, no);
392
- box.appendChild(ask);
393
- }
394
- if (t.deskAsk) {
395
- box.appendChild(el('div', 'sub', `要在桌面上:${t.deskAsk}`));
396
- const ask = el('div', 'row');
397
- const yes = el('button', 'btn', '同意');
398
- const no = el('button', 'btn ghost', '不同意');
399
- yes.onclick = () => cloudCall('decideDesk', { jobId: t.jobId, allow: true });
400
- no.onclick = () => cloudCall('decideDesk', { jobId: t.jobId, allow: false });
401
- ask.append(yes, no);
402
- box.appendChild(ask);
403
- }
404
- const row = el('div', 'row');
405
- const stop = el('button', 'btn ghost', '收回(关掉它的标签页)');
406
- stop.onclick = () => cloudCall('endTask', { jobId: t.jobId });
407
- row.appendChild(stop);
408
- box.appendChild(row);
383
+ askBox(`新任务:${title}`, lines, '允许', '拒绝', 'decide', t.jobId);
384
+ continue;
409
385
  }
386
+ if (t.ask?.length) askBox(title, [`还想访问网站:${t.ask.join('、')}${t.askWhy ? `。用途:${t.askWhy}` : ''}`], '允许这些网站', '不允许', 'decideSites', t.jobId);
387
+ if (t.folderAsk?.length) askBox(title, [`还想读取本地文件夹:${t.folderAsk.join('、')}${t.folderAskWhy ? `。用途:${t.folderAskWhy}` : ''}`], '允许这些文件夹', '不允许', 'decideFolders', t.jobId);
388
+ if (t.appAsk?.length) askBox(title, [`还想操作桌面应用:${t.appAsk.join('、')}${t.appAskWhy ? `。用途:${t.appAskWhy}` : ''}`], '允许这些应用', '不允许', 'decideApps', t.jobId);
389
+ if (t.deskAsk) askBox(title, [`要在桌面上:${t.deskAsk}`], '同意', '不同意', 'decideDesk', t.jobId);
390
+
391
+ const box = el('div', 'task');
392
+ box.appendChild(el('div', 't', title));
393
+ const where = [...new Set([...(t.use || []), ...(t.sites || [])])].join('、') || '(还没有网站)';
394
+ box.appendChild(el('div', 'sub', `进行中 · ${where} · ${t.count} 条命令 · ${relTime(t.lastAt)}`));
395
+ if (t.folders?.length) box.appendChild(el('div', 'sub', `可读本地文件夹:${t.folders.join('、')}`));
396
+ if (t.apps?.length) box.appendChild(el('div', 'sub', `可操作桌面应用:${t.apps.join('、')}`));
397
+ const row = el('div', 'row');
398
+ const stop = el('button', 'btn ghost', '收回(关掉它的标签页)');
399
+ stop.onclick = () => cloudCall('endTask', { jobId: t.jobId });
400
+ row.appendChild(stop);
401
+ box.appendChild(row);
410
402
  tasks.appendChild(box);
411
403
  }
404
+ syncAlert();
412
405
 
413
406
  const log = $('cloudLog');
414
407
  log.textContent = '';
@@ -449,7 +442,9 @@ $('deskOn').onclick = async () => {
449
442
  const granted = await chrome.permissions.request({ permissions: ['nativeMessaging'] }).catch(() => false);
450
443
  if (!granted) { $('deskState').textContent = '没有拿到权限,桌面能力保持关闭'; return; }
451
444
  $('deskState').textContent = '启用中…第一次启用要重载扩展,侧栏若关掉了,重新打开即可';
452
- await cloudCall('desktopEnable');
445
+ const r = await cloudCall('desktopEnable');
446
+ // 失败原因写在桌面这一栏,不要留着「启用中…」,也不用在顶上再报一遍
447
+ if (r && !r.ok && r.error) { $('deskState').textContent = r.error; $('cloudErr').hidden = true; }
453
448
  };
454
449
  $('deskOff').onclick = async () => {
455
450
  await cloudCall('desktopDisable');
@@ -460,7 +455,7 @@ $('deskOff').onclick = async () => {
460
455
 
461
456
  chrome.storage.onChanged.addListener((changes, area) => {
462
457
  const keys0 = Object.keys(changes);
463
- if (keys0.some((k) => ['cloud', 'cloudTasks', 'cloudLog', 'cloudStatus', 'cloudClaim', 'folderTick', 'cloudDesktop'].includes(k))) void cloudCall('state');
458
+ if (keys0.some((k) => ['cloud', 'cloudTasks', 'cloudLog', 'cloudStatus', 'cloudClaim', 'folderTick', 'cloudDesktop', 'cloudDesktopError'].includes(k))) void cloudCall('state');
464
459
  if (area !== 'session') return;
465
460
  const keys = Object.keys(changes);
466
461
  if (keys.includes(`panel:${tabId}`) || keys.includes('liveSids')) renderPage();
@@ -0,0 +1,217 @@
1
+ // 桌面完全模式 —— 云端任务在这台电脑上拿到和本机 agent(goose 一类)一样的能力:
2
+ // 跑命令、读写任意文件、agent-desktop 的全部命令。
3
+ //
4
+ // 只有用户在本机运行过 `vcorp-chrome desktop full on` 才开(见 paths.js 的 fullMode),
5
+ // 原生宿主每条命令都现读开关。这一层不做「哪个能碰」的判断——那是第二阶段安全方案的事。
6
+ //
7
+ // 原生消息回包上限 1MB:大结果先落到临时文件,扩展再用 page 一段一段取回(见 pageOut / pageRead)。
8
+ import { spawn } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+
14
+ const OUT_MAX = 4 * 1024 * 1024; // shell 的 stdout / stderr 各留这么多
15
+ const SHELL_TIMEOUT_MAX = 7 * 60 * 1000; // 服务器那边一条命令等 8 分钟
16
+ export const FILE_READ_MAX = 16 * 1024 * 1024;
17
+ const LIST_MAX = 2000;
18
+
19
+ const fail = (code, message) => ({ ok: false, error: { code, message, disposition: { delivery: 'not_delivered', retry: 'safe' } } });
20
+
21
+ // ~ 和相对路径都从用户主目录算起
22
+ export function resolvePath(p) {
23
+ const s = String(p ?? '').trim();
24
+ if (!s || s.includes('\0')) return '';
25
+ if (s === '~') return os.homedir();
26
+ if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(os.homedir(), s.slice(2));
27
+ return path.resolve(os.homedir(), s);
28
+ }
29
+
30
+ // 用哪个壳。Windows 默认 PowerShell,并把输出编码钉成 UTF-8,中文不乱码。
31
+ export function shellArgv(command, which = '') {
32
+ const win = process.platform === 'win32';
33
+ const sh = String(which || (win ? 'powershell' : 'sh')).toLowerCase();
34
+ if (sh === 'powershell' || sh === 'pwsh') {
35
+ const exe = sh === 'pwsh' ? 'pwsh' : win ? 'powershell.exe' : 'pwsh';
36
+ const pre = '[Console]::OutputEncoding=[Text.Encoding]::UTF8;$OutputEncoding=[Text.Encoding]::UTF8;$ProgressPreference="SilentlyContinue";';
37
+ return [exe, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', pre + command]];
38
+ }
39
+ if (sh === 'cmd') return ['cmd.exe', ['/d', '/s', '/c', `chcp 65001 >nul & ${command}`]];
40
+ if (sh === 'bash') return ['bash', ['-lc', command]];
41
+ if (sh === 'sh') return ['/bin/sh', ['-c', command]];
42
+ return null;
43
+ }
44
+
45
+ export function runShell(p = {}) {
46
+ const command = String(p.command ?? '');
47
+ if (!command.trim()) return Promise.resolve(fail('BAD_PARAMS', 'shell 要给 command'));
48
+ const argv = shellArgv(command, p.shell);
49
+ if (!argv) return Promise.resolve(fail('BAD_PARAMS', 'shell 只认 powershell / pwsh / cmd / bash / sh'));
50
+ const cwd = p.cwd ? resolvePath(p.cwd) : os.homedir();
51
+ if (!cwd || !fs.existsSync(cwd)) return Promise.resolve(fail('NOT_FOUND', `没有这个目录:${String(p.cwd).slice(0, 200)}`));
52
+ const timeoutMs = Math.min(Math.max(Number(p.timeout) || 60000, 1000), SHELL_TIMEOUT_MAX);
53
+ const env = { ...process.env, ...(p.env && typeof p.env === 'object' ? Object.fromEntries(Object.entries(p.env).map(([k, v]) => [k, String(v)])) : {}) };
54
+
55
+ return new Promise((resolve) => {
56
+ const started = Date.now();
57
+ const out = { stdout: [], stderr: [], n: { stdout: 0, stderr: 0 }, cut: false };
58
+ let timedOut = false;
59
+ let child;
60
+ try {
61
+ child = spawn(argv[0], argv[1], { cwd, env, windowsHide: true, stdio: [p.stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
62
+ } catch (e) {
63
+ resolve(fail('SPAWN_FAILED', e.message));
64
+ return;
65
+ }
66
+ const keep = (which) => (b) => {
67
+ if (out.n[which] >= OUT_MAX) { out.cut = true; return; }
68
+ const room = OUT_MAX - out.n[which];
69
+ const part = b.length > room ? b.subarray(0, room) : b;
70
+ if (part.length < b.length) out.cut = true;
71
+ out[which].push(part);
72
+ out.n[which] += part.length;
73
+ };
74
+ child.stdout.on('data', keep('stdout'));
75
+ child.stderr.on('data', keep('stderr'));
76
+ if (p.stdin !== undefined) { child.stdin.on('error', () => {}); child.stdin.end(String(p.stdin)); }
77
+ const timer = setTimeout(() => {
78
+ timedOut = true;
79
+ // Windows 上 kill 只杀壳本身,它起的子进程还在;taskkill /t 连树一起收
80
+ if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' }).on('error', () => {});
81
+ else child.kill('SIGKILL');
82
+ }, timeoutMs);
83
+ child.on('error', (e) => { clearTimeout(timer); resolve(fail(e.code === 'ENOENT' ? 'NOT_FOUND' : 'SPAWN_FAILED', `${argv[0]}:${e.message}`)); });
84
+ child.on('close', (code, signal) => {
85
+ clearTimeout(timer);
86
+ resolve({
87
+ ok: true,
88
+ data: {
89
+ exitCode: code, signal: signal || null, timedOut, truncated: out.cut, ms: Date.now() - started, cwd,
90
+ stdout: Buffer.concat(out.stdout).toString('utf8'),
91
+ stderr: Buffer.concat(out.stderr).toString('utf8'),
92
+ },
93
+ });
94
+ });
95
+ });
96
+ }
97
+
98
+ function statOf(abs, st) {
99
+ return {
100
+ path: abs,
101
+ type: st.isDirectory() ? 'dir' : st.isSymbolicLink() ? 'link' : st.isFile() ? 'file' : 'other',
102
+ size: st.size,
103
+ mtime: st.mtime.toISOString(),
104
+ };
105
+ }
106
+
107
+ export async function fileOp(p = {}) {
108
+ const action = String(p.action || '');
109
+ const abs = resolvePath(p.path);
110
+ if (!abs) return fail('BAD_PARAMS', 'file 要给 path');
111
+ const fsp = fs.promises;
112
+ try {
113
+ switch (action) {
114
+ case 'stat':
115
+ return { ok: true, data: statOf(abs, await fsp.lstat(abs)) };
116
+ case 'list': {
117
+ const depth = Math.min(Math.max(Math.floor(Number(p.depth) || 1), 1), 6);
118
+ const items = [];
119
+ let more = false;
120
+ const walk = async (dir, d) => {
121
+ let ents;
122
+ try { ents = await fsp.readdir(dir, { withFileTypes: true }); } catch (e) { if (dir === abs) throw e; return; }
123
+ for (const e of ents) {
124
+ if (items.length >= LIST_MAX) { more = true; return; }
125
+ const full = path.join(dir, e.name);
126
+ let st = null;
127
+ try { st = await fsp.lstat(full); } catch { /* 刚被删、没权限 */ }
128
+ items.push(st ? statOf(full, st) : { path: full, type: 'unknown' });
129
+ if (e.isDirectory() && d < depth) await walk(full, d + 1);
130
+ }
131
+ };
132
+ await walk(abs, 1);
133
+ return { ok: true, data: { path: abs, items, more } };
134
+ }
135
+ case 'read': {
136
+ const st = await fsp.stat(abs);
137
+ if (st.isDirectory()) return fail('IS_DIR', `${abs} 是目录:用 action:"list"`);
138
+ const offset = Math.max(Math.floor(Number(p.offset) || 0), 0);
139
+ const length = Math.min(Math.max(Math.floor(Number(p.length) || FILE_READ_MAX), 0), FILE_READ_MAX);
140
+ const fh = await fsp.open(abs, 'r');
141
+ try {
142
+ const buf = Buffer.alloc(Math.min(length, Math.max(st.size - offset, 0)));
143
+ const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
144
+ const body = buf.subarray(0, bytesRead);
145
+ const enc = p.encoding === 'base64' ? 'base64' : 'utf8';
146
+ return { ok: true, data: { path: abs, size: st.size, offset, bytes: bytesRead, more: offset + bytesRead < st.size, encoding: enc, content: body.toString(enc) } };
147
+ } finally { await fh.close(); }
148
+ }
149
+ case 'write': {
150
+ if (p.content === undefined) return fail('BAD_PARAMS', 'write 要给 content');
151
+ const buf = Buffer.from(String(p.content), p.encoding === 'base64' ? 'base64' : 'utf8');
152
+ await fsp.mkdir(path.dirname(abs), { recursive: true });
153
+ if (p.append) await fsp.appendFile(abs, buf); else await fsp.writeFile(abs, buf);
154
+ return { ok: true, data: { path: abs, bytes: buf.length, appended: !!p.append } };
155
+ }
156
+ case 'mkdir':
157
+ await fsp.mkdir(abs, { recursive: true });
158
+ return { ok: true, data: { path: abs } };
159
+ case 'delete':
160
+ await fsp.rm(abs, { recursive: !!p.recursive, force: false });
161
+ return { ok: true, data: { path: abs, deleted: true } };
162
+ case 'move':
163
+ case 'copy': {
164
+ const to = resolvePath(p.to);
165
+ if (!to) return fail('BAD_PARAMS', `${action} 要给 to`);
166
+ await fsp.mkdir(path.dirname(to), { recursive: true });
167
+ if (action === 'move') await fsp.rename(abs, to);
168
+ else await fsp.cp(abs, to, { recursive: true, force: !!p.overwrite, errorOnExist: !p.overwrite });
169
+ return { ok: true, data: { from: abs, to } };
170
+ }
171
+ default:
172
+ return fail('BAD_PARAMS', 'file 的 action 只认 stat / list / read / write / mkdir / delete / move / copy');
173
+ }
174
+ } catch (e) {
175
+ const code = { ENOENT: 'NOT_FOUND', EACCES: 'PERM_DENIED', EPERM: 'PERM_DENIED', EEXIST: 'EXISTS', ENOTEMPTY: 'NOT_EMPTY', EISDIR: 'IS_DIR' }[e.code] || 'FS_ERROR';
176
+ return fail(code, `${e.code || ''} ${e.message}`.trim());
177
+ }
178
+ }
179
+
180
+ // ---------- 分页 ----------
181
+ //
182
+ // 宿主 → Chrome 一条最多 1MB。超出的回包整份写进临时目录,先回 { paged },
183
+ // 扩展再一段一段 page 取回、拼好再解析。最后一段取走时删文件;没取完的十分钟后清掉。
184
+
185
+ const PAGE_DIR = path.join(os.tmpdir(), 'vcorp-desktop-pages');
186
+ const PAGE_RAW = 600 * 1024; // 每段原始字节,base64 后约 800KB
187
+ const PAGE_TTL_MS = 10 * 60 * 1000;
188
+ const PAGE_ID_RE = /^[a-f0-9]{24}$/;
189
+
190
+ export function pageOut(body) {
191
+ fs.mkdirSync(PAGE_DIR, { recursive: true });
192
+ const now = Date.now();
193
+ for (const f of fs.readdirSync(PAGE_DIR)) {
194
+ try { if (now - fs.statSync(path.join(PAGE_DIR, f)).mtimeMs > PAGE_TTL_MS) fs.rmSync(path.join(PAGE_DIR, f), { force: true }); } catch { /* 别的进程在删 */ }
195
+ }
196
+ const id = crypto.randomBytes(12).toString('hex');
197
+ fs.writeFileSync(path.join(PAGE_DIR, `${id}.json`), body, { mode: 0o600 });
198
+ return { ok: true, paged: { id, size: body.length, chunk: PAGE_RAW } };
199
+ }
200
+
201
+ export function pageRead(id, offset) {
202
+ if (!PAGE_ID_RE.test(String(id))) return fail('BAD_PARAMS', 'page id 不合法');
203
+ const file = path.join(PAGE_DIR, `${id}.json`);
204
+ let fd;
205
+ try { fd = fs.openSync(file, 'r'); } catch { return fail('NOT_FOUND', '这一页已经过期或取完了'); }
206
+ try {
207
+ const size = fs.fstatSync(fd).size;
208
+ const at = Math.max(Math.floor(Number(offset) || 0), 0);
209
+ const buf = Buffer.alloc(Math.max(Math.min(PAGE_RAW, size - at), 0));
210
+ const n = fs.readSync(fd, buf, 0, buf.length, at);
211
+ const done = at + n >= size;
212
+ fs.closeSync(fd);
213
+ fd = null;
214
+ if (done) fs.rmSync(file, { force: true });
215
+ return { ok: true, page: { offset: at, data: buf.subarray(0, n).toString('base64'), done } };
216
+ } finally { if (fd !== null && fd !== undefined) fs.closeSync(fd); }
217
+ }