cicy-desktop 2.1.280 → 2.1.285

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.
@@ -35,14 +35,36 @@
35
35
  .name { font-weight: 650; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
36
36
  .chip { flex: none; padding: 1px 6px; border-radius: 999px; background: var(--head); color: #b4b4bb; font: 11px ui-monospace, monospace; }
37
37
  .st { flex: none; color: var(--muted); font-size: 11px; white-space: nowrap; margin-left: auto; }
38
- .line2 { display: flex; align-items: center; gap: 6px; margin-top: 5px; padding-left: 16px; font-size: 11px; color: var(--muted); min-width: 0; }
39
- .line2 .val { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font: 11px ui-monospace, monospace; color: #b4b4bb; }
40
- .line2 .val:hover { color: #fff; text-decoration: underline dotted; }
41
- .line2 .pen { padding: 1px 5px; font-size: 12px; }
42
- .line2 .edit { display: none; flex: 1; gap: 4px; min-width: 0; }
43
- .line2.editing .edit { display: flex; }
44
- .line2.editing .val, .line2.editing .pen, .line2.editing .lbl { display: none; }
45
- input[data-role="proxy"] { flex: 1; min-width: 0; height: 26px; border: 1px solid var(--accent); border-radius: 6px; padding: 0 8px; background: #111113; color: var(--fg); font: 11.5px ui-monospace, monospace; outline: 0; }
38
+ .line2 { display: flex; align-items: center; gap: 8px; margin-top: 4px; padding-left: 16px; font-size: 11.5px; color: var(--muted); min-width: 0; }
39
+ .line2 .tg { flex: none; max-width: 55%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #b4b4bb; }
40
+ .line2 .tg b { color: var(--fg); font-weight: 600; }
41
+ .line2 .tg.none { color: #6b6b72; }
42
+ .line2 .note { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #9a9aa2; cursor: text; outline: 0; border-radius: 4px; padding: 0 4px; margin: 0 -4px; }
43
+ .line2 .note.empty { color: #55555c; font-style: italic; }
44
+ .line2 .note:hover { color: #fff; }
45
+ /* 编辑态就地变成可输入,尺寸/位置与只读态完全一致,不发生位移 */
46
+ .line2 .note.editing { color: var(--fg); font-style: normal; background: #111113; box-shadow: 0 0 0 1px var(--accent); text-overflow: clip; }
47
+ .line2 .ip { flex: none; max-width: 45%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0 6px; border-radius: 999px; background: var(--head); color: #b4b4bb; font: 11px ui-monospace, monospace; line-height: 16px; cursor: pointer; }
48
+ .line2 .ip.none { color: #6b6b72; font-family: inherit; }
49
+ .line2 .ip.busy { color: #f0b429; }
50
+ .line2 .ip.bad { color: #ff8a8a; }
51
+ .line2 .ip:hover { color: #fff; }
52
+ .cfg { display: none; margin: 8px 0 2px 16px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--head); }
53
+ .row.cfg-open .cfg { display: block; }
54
+ .row.cfg-open .gear { color: var(--accent); }
55
+ .cfg .f { display: flex; align-items: center; gap: 6px; }
56
+ .cfg .f + .f { margin-top: 6px; }
57
+ .cfg label { flex: none; width: 34px; font-size: 11px; color: var(--muted); }
58
+ .cfg input { flex: 1; min-width: 0; height: 26px; border: 1px solid var(--line); border-radius: 6px; padding: 0 8px; background: #111113; color: var(--fg); font: 11.5px ui-monospace, monospace; outline: 0; }
59
+ .cfg input:focus { border-color: var(--accent); }
60
+ .cfg .hint { font-size: 11px; color: var(--muted); padding-left: 40px; }
61
+ .cfg .hint b { color: #b4b4bb; font-weight: 500; }
62
+ .cfg .danger { display: flex; align-items: center; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); }
63
+ .cfg .danger .sp { flex: 1; }
64
+ .cfg .confirm { display: none; align-items: center; gap: 6px; margin-top: 8px; padding: 6px 8px; border-radius: 6px; background: rgba(255,92,92,.12); color: #ffb3b3; font-size: 11.5px; }
65
+ .cfg .confirm.on { display: flex; }
66
+ .cfg .confirm .sp { flex: 1; }
67
+ button.del { background: #b3261e; }
46
68
  .row-err { padding: 4px 0 0 16px; color: #ff8a8a; font-size: 11px; display: none; }
47
69
  .row-err.on { display: block; }
48
70
  #empty { display: none; padding: 40px 24px; color: var(--muted); text-align: center; line-height: 1.8; }
@@ -92,7 +114,7 @@
92
114
  <div>
93
115
  <h2>无法连接 Telegram</h2>
94
116
  <p id="err-detail"></p>
95
- <div class="btns"><button id="err-retry">重试</button><button class="ghost" id="err-direct">改为直连并重试</button><button class="ghost" id="err-edit">改代理</button></div>
117
+ <div class="btns"><button id="err-retry">重试</button><button class="ghost" id="err-cfg">代理设置</button></div>
96
118
  </div>
97
119
  </div>
98
120
  </div>
@@ -120,6 +142,43 @@
120
142
  const stateOf = (idx) => cellState.get(Number(idx)) || {};
121
143
  const dotClass = (st) => st.loading ? 'loading' : st.failed ? 'failed' : (st.url ? 'ok' : '');
122
144
  const stateText = (st, opened) => st.loading ? '加载中…' : st.failed ? '连接失败' : st.url ? '已加载' : (opened ? '' : '未打开');
145
+ const fmtAgo = (iso) => { const t = Date.parse(iso || ''); if (!t) return ''; const m = Math.round((Date.now() - t) / 60000); return m < 1 ? '刚刚' : m < 60 ? `${m} 分钟前` : m < 1440 ? `${Math.round(m / 60)} 小时前` : `${Math.round(m / 1440)} 天前`; };
146
+ function renderIp(p, mode) {
147
+ const row = rows.get(p.accountIdx); if (!row) return;
148
+ const el = row.querySelector('.ip');
149
+ el.className = 'ip';
150
+ if (mode === 'busy') { el.classList.add('busy'); el.textContent = '测 IP…'; el.title = ''; return; }
151
+ if (mode && mode.error) { el.classList.add('bad'); el.textContent = 'IP 探测失败'; el.title = mode.error + '(点击重试)'; return; }
152
+ const info = p.ipInfo;
153
+ if (info && info.ip) {
154
+ el.textContent = info.area ? `${info.area} · ${info.ip}` : info.ip;
155
+ el.title = `出口 IP ${info.ip}${info.area ? ',' + info.area : ''},${fmtAgo(info.probedAt) || '未知时间'}探测(点击重测)`;
156
+ } else { el.classList.add('none'); el.textContent = '测 IP'; el.title = '经该 Profile 的代理探测出口 IP / 地区'; }
157
+ }
158
+ async function probeIp(p) {
159
+ renderIp(p, 'busy');
160
+ try {
161
+ const r = await window.panelAPI.probeIp(p.accountIdx);
162
+ if (r && r.ipInfo) { p.ipInfo = r.ipInfo; renderIp(p); }
163
+ else renderIp(p, { error: (r && r.error) || '探测失败' });
164
+ } catch (e) { renderIp(p, { error: e.message || String(e) }); }
165
+ }
166
+ // 身份:优先用本次会话探到的(cellState.identity),否则用 profile 里存的(上次登录)。
167
+ const identityOf = (p) => stateOf(p.accountIdx).identity || p.telegram || null;
168
+ function renderIdentity(p) {
169
+ const row = rows.get(p.accountIdx); if (!row) return;
170
+ const el = row.querySelector('.tg');
171
+ const it = identityOf(p);
172
+ if (it && (it.username || it.displayName || it.phone)) {
173
+ el.classList.remove('none');
174
+ el.innerHTML = (it.username ? `<b>@${esc(it.username)}</b>` : '') + (it.displayName ? `${it.username ? ' · ' : ''}${esc(it.displayName)}` : '') + (!it.username && !it.displayName && it.phone ? `+${esc(it.phone)}` : '');
175
+ el.title = [it.username && `@${it.username}`, it.displayName, it.phone && `+${it.phone}`].filter(Boolean).join(' ');
176
+ } else {
177
+ el.classList.add('none');
178
+ el.textContent = openedProfiles.has(p.accountIdx) && stateOf(p.accountIdx).url ? '未登录' : '—';
179
+ el.title = '';
180
+ }
181
+ }
123
182
 
124
183
  function flash(text, ms = 2500) {
125
184
  statusEl.textContent = text; statusEl.dataset.sticky = '1';
@@ -143,8 +202,8 @@
143
202
  function describeError(p, st) {
144
203
  const e = st.error || {};
145
204
  const desc = e.description ? `<code>${esc(e.description)}</code>` : '';
146
- if (p.proxy) return `代理 <code>${esc(p.proxy)}</code> 不可用或无法访问 Telegram。${desc}<br>请确认代理已启动,或改为直连 / 换一个代理。`;
147
- return `当前为直连,网络无法访问 Telegram。${desc}<br>建议设置一个可用的代理。`;
205
+ if (p.proxy) return `代理 <code>${esc(p.proxy)}</code> 不可用或无法访问 Telegram。${desc}<br>点 ⚙ 检查或更换该 Profile 的代理,可用「测 IP」验证。`;
206
+ return `当前为直连,网络无法访问 Telegram。${desc}<br>点 ⚙ 为该 Profile 设置代理。`;
148
207
  }
149
208
 
150
209
  function renderPreviewState() {
@@ -169,43 +228,77 @@
169
228
  const row = document.createElement('div');
170
229
  row.className = 'row'; row.dataset.id = String(p.accountIdx); row.title = `persist:sandbox-${p.accountIdx}`;
171
230
  row.innerHTML = `
172
- <div class="line1"><span class="dot"></span><span class="name">${esc(p.name)}</span><span class="chip">#${p.accountIdx}</span><span class="st"></span><button class="icon reload" title="重新加载">⟳</button></div>
173
- <div class="line2"><span class="lbl">代理</span><span class="val" title="点击修改"></span><button class="icon pen" title="修改代理">✎</button>
174
- <div class="edit"><input data-role="proxy" placeholder="留空 = 直连,例如 http://127.0.0.1:20001" spellcheck="false"><button class="sm save">保存</button><button class="sm ghost cancel">取消</button></div></div>
231
+ <div class="line1"><span class="dot"></span><span class="name">${esc(p.name)}</span><span class="chip">#${p.accountIdx}</span><span class="st"></span><button class="icon gear" title="代理设置">⚙</button><button class="icon reload" title="重新加载">⟳</button></div>
232
+ <div class="line2"><span class="tg none">—</span><span class="note" data-role="note" title="点击编辑备注,Enter 保存 / Esc 取消" spellcheck="false"></span><span class="ip none" title="经该 Profile 的代理探测出口 IP / 地区">测 IP</span></div>
233
+ <div class="cfg">
234
+ <div class="f"><label>代理</label><input data-role="proxy" placeholder="留空 = 直连,例如 http://127.0.0.1:20001" spellcheck="false"><button class="sm save">保存</button><button class="sm ghost test">测 IP</button></div>
235
+ <div class="hint">保存后已打开的会话会重新加载;<b>测 IP</b> 经该代理访问 IP 服务,结果显示在上方。</div>
236
+ <div class="danger"><span>删除此 Profile 会一并清除它的 Telegram 登录态和缓存。</span><span class="sp"></span><button class="sm ghost rm">删除</button></div>
237
+ <div class="confirm"><span>确认删除 <b>${esc(p.name)} #${p.accountIdx}</b>?不可恢复。</span><span class="sp"></span><button class="sm del rm-yes">确认删除</button><button class="sm ghost rm-no">取消</button></div>
238
+ </div>
175
239
  <div class="row-err"></div>`;
176
- const line2 = row.querySelector('.line2');
177
- const val = line2.querySelector('.val');
178
- const input = line2.querySelector('[data-role="proxy"]');
179
240
  const errEl = row.querySelector('.row-err');
180
- const showProxy = () => { val.textContent = p.proxy || '直连'; };
181
- showProxy();
182
- const startEdit = (e) => { e && e.stopPropagation(); line2.classList.add('editing'); input.value = p.proxy; input.focus(); input.select(); };
183
- const stopEdit = (e) => { e && e.stopPropagation(); line2.classList.remove('editing'); errEl.classList.remove('on'); };
184
- const save = async (value) => {
185
- const btn = line2.querySelector('.save'); btn.disabled = true; errEl.classList.remove('on');
241
+ const noteEl = row.querySelector('.note');
242
+ const showNote = () => { noteEl.textContent = p.note || '添加备注'; noteEl.classList.toggle('empty', !p.note); };
243
+ showNote();
244
+ let editing = false;
245
+ const startEdit = (e) => {
246
+ e && e.stopPropagation();
247
+ if (editing) return;
248
+ editing = true;
249
+ noteEl.classList.add('editing'); noteEl.classList.remove('empty');
250
+ noteEl.textContent = p.note || '';
251
+ noteEl.contentEditable = 'plaintext-only';
252
+ noteEl.focus();
253
+ const range = document.createRange(); range.selectNodeContents(noteEl);
254
+ const sel = getSelection(); sel.removeAllRanges(); sel.addRange(range);
255
+ };
256
+ const stopEdit = () => { editing = false; noteEl.contentEditable = 'false'; noteEl.classList.remove('editing'); showNote(); };
257
+ const saveNote = async () => {
258
+ if (!editing) return;
259
+ const value = noteEl.textContent.replace(/\s+/g, ' ').trim().slice(0, 500);
260
+ stopEdit();
261
+ if (value === (p.note || '')) return;
186
262
  try {
187
- const updated = await window.panelAPI.setProfileProxy(p.accountIdx, value);
188
- p.proxy = updated.proxy; showProxy(); stopEdit();
189
- // 代理只在会话加载时生效,所以已打开的必须重载。**没打开过的不要**在这里置
190
- // loading:主进程没有对应的 cell,既不会创建视图也不会有 panelcells:state 回来,
191
- // 行会永远卡在「加载中…」(实测 bug)。保持「未打开」,下次打开自然用新代理。
192
- if (openedProfiles.has(p.accountIdx)) {
193
- reloadProfile(p.accountIdx);
194
- flash(updated.proxy ? `#${p.accountIdx} 代理已应用,重新加载中` : `#${p.accountIdx} 已切换直连,重新加载中`);
195
- } else {
196
- flash(updated.proxy ? `#${p.accountIdx} 代理已保存,打开时生效` : `#${p.accountIdx} 已保存为直连,打开时生效`);
197
- }
263
+ const updated = await window.panelAPI.setProfileNote(p.accountIdx, value);
264
+ p.note = updated.note; showNote();
198
265
  } catch (e) { errEl.textContent = e.message || String(e); errEl.classList.add('on'); }
199
- finally { btn.disabled = false; }
200
266
  };
267
+ noteEl.onclick = (e) => { e.stopPropagation(); startEdit(e); };
268
+ noteEl.onkeydown = (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); saveNote(); } if (e.key === 'Escape') { e.preventDefault(); stopEdit(); } };
269
+ noteEl.onblur = () => saveNote();
270
+ // 代理设置抽屉(⚙):不常用,默认收起,列表保持干净。
271
+ const proxyInput = row.querySelector('[data-role="proxy"]');
272
+ const saveBtn = row.querySelector('.cfg .save');
273
+ const toggleCfg = (e) => { e.stopPropagation(); const open = !row.classList.contains('cfg-open'); row.classList.toggle('cfg-open', open); if (open) { proxyInput.value = p.proxy || ''; proxyInput.focus(); } };
274
+ const saveProxy = async () => {
275
+ saveBtn.disabled = true; errEl.classList.remove('on');
276
+ try {
277
+ const updated = await window.panelAPI.setProfileProxy(p.accountIdx, proxyInput.value.trim());
278
+ p.proxy = updated.proxy; p.ipInfo = null; renderIp(p);
279
+ if (openedProfiles.has(p.accountIdx)) { reloadProfile(p.accountIdx); flash(`#${p.accountIdx} 代理已应用,重新加载中`); }
280
+ else flash(`#${p.accountIdx} 代理已保存,打开时生效`);
281
+ row.classList.remove('cfg-open');
282
+ } catch (e) { errEl.textContent = e.message || String(e); errEl.classList.add('on'); }
283
+ finally { saveBtn.disabled = false; }
284
+ };
285
+ row.querySelector('.gear').onclick = toggleCfg;
286
+ row.querySelector('.cfg').onclick = (e) => e.stopPropagation();
287
+ saveBtn.onclick = saveProxy;
288
+ proxyInput.onkeydown = (e) => { e.stopPropagation(); if (e.key === 'Enter') saveProxy(); if (e.key === 'Escape') row.classList.remove('cfg-open'); };
289
+ row.querySelector('.cfg .test').onclick = () => probeIp(p);
290
+ row.querySelector('.ip').onclick = (e) => { e.stopPropagation(); probeIp(p); };
291
+ const confirmEl = row.querySelector('.cfg .confirm');
292
+ row.querySelector('.rm').onclick = () => { confirmEl.classList.add('on'); row.querySelector('.rm-yes').focus(); };
293
+ row.querySelector('.rm-no').onclick = () => confirmEl.classList.remove('on');
294
+ row.querySelector('.rm-yes').onclick = async () => {
295
+ const btn = row.querySelector('.rm-yes'); btn.disabled = true;
296
+ try { await removeProfile(p); }
297
+ catch (e) { errEl.textContent = e.message || String(e); errEl.classList.add('on'); btn.disabled = false; }
298
+ };
299
+ row._openCfg = () => { if (!row.classList.contains('cfg-open')) toggleCfg(new Event('click')); row.scrollIntoView({ block: 'nearest' }); };
201
300
  row.onclick = () => selectProfile(p.accountIdx);
202
- val.onclick = startEdit; line2.querySelector('.pen').onclick = startEdit;
203
- line2.querySelector('.cancel').onclick = stopEdit;
204
- line2.querySelector('.save').onclick = (e) => { e.stopPropagation(); save(input.value); };
205
- input.onclick = (e) => e.stopPropagation();
206
- input.onkeydown = (e) => { e.stopPropagation(); if (e.key === 'Enter') save(input.value); if (e.key === 'Escape') stopEdit(); };
207
301
  row.querySelector('.reload').onclick = (e) => { e.stopPropagation(); reloadProfile(p.accountIdx); };
208
- row._save = save; row._startEdit = startEdit;
209
302
  return row;
210
303
  }
211
304
 
@@ -226,6 +319,8 @@
226
319
  if (!rows.has(p.accountIdx)) { const row = buildRow(p); rows.set(p.accountIdx, row); rowsEl.appendChild(row); }
227
320
  rows.get(p.accountIdx).classList.toggle('active', !!selected && selected.accountIdx === p.accountIdx);
228
321
  renderRowState(p.accountIdx);
322
+ renderIdentity(p);
323
+ renderIp(p);
229
324
  }
230
325
  }
231
326
 
@@ -250,8 +345,10 @@
250
345
  const next = { ...prev, loading: !!state.loading };
251
346
  if (state.url !== undefined) { next.url = failed ? '' : state.url; next.title = state.title || ''; }
252
347
  if (!state.loading) { next.failed = failed; next.error = state.error || (failed ? prev.error : null); }
348
+ if (state.identity) next.identity = state.identity;
253
349
  cellState.set(idx, next);
254
350
  renderRowState(idx);
351
+ { const p = profiles.find((x) => x.accountIdx === idx); if (p) renderIdentity(p); }
255
352
  if (selected && selected.accountIdx === idx) renderPreviewState();
256
353
  }
257
354
  async function pullStates({ adopt = false } = {}) {
@@ -277,6 +374,19 @@
277
374
  selectProfile(target);
278
375
  }
279
376
 
377
+ async function removeProfile(p) {
378
+ const idx = p.accountIdx;
379
+ await window.panelAPI.removeProfile(idx);
380
+ openedProfiles.delete(idx); cellState.delete(idx);
381
+ const row = rows.get(idx); if (row) row.remove(); rows.delete(idx);
382
+ profiles = profiles.filter((x) => x.accountIdx !== idx);
383
+ if (selected && selected.accountIdx === idx) {
384
+ const next = profiles[0] ? profiles[0].accountIdx : 0;
385
+ if (next) selectProfile(next); else { selected = null; localStorage.removeItem('telegram-matrix-profile'); selectedName.textContent = '未选择 Profile'; render(); renderPreviewState(); syncPreview(); }
386
+ } else render();
387
+ flash(`Profile #${idx} 已删除`);
388
+ }
389
+
280
390
  addButton.onclick = async () => {
281
391
  addButton.disabled = true; flash('正在创建…', 60000);
282
392
  try {
@@ -289,8 +399,7 @@
289
399
  };
290
400
  document.getElementById('reload').onclick = () => { if (selected) reloadProfile(selected.accountIdx); };
291
401
  document.getElementById('err-retry').onclick = () => { if (selected) reloadProfile(selected.accountIdx); };
292
- document.getElementById('err-direct').onclick = () => { const row = selected && rows.get(selected.accountIdx); if (row) row._save(''); };
293
- document.getElementById('err-edit').onclick = () => { const row = selected && rows.get(selected.accountIdx); if (row) { row.scrollIntoView({ block: 'nearest' }); row._startEdit(); } };
402
+ document.getElementById('err-cfg').onclick = () => { const row = selected && rows.get(selected.accountIdx); if (row) row._openCfg(); };
294
403
 
295
404
  new ResizeObserver(() => requestAnimationFrame(syncPreview)).observe(preview);
296
405
  window.addEventListener('resize', syncPreview);
@@ -9,6 +9,10 @@ function sleep(ms) {
9
9
  }
10
10
 
11
11
  function withFileLock(lockPath, fn, { timeoutMs = 5000, retryDelayMs = 50 } = {}) {
12
+ // The lock is a directory next to the target file; on a fresh machine the
13
+ // parent (~/cicy-ai) does not exist yet and mkdir(lock) fails with ENOENT,
14
+ // which used to make every first-run write (api_token, terms, deviceInfo) fail.
15
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
12
16
  const startedAt = Date.now();
13
17
  while (true) {
14
18
  try {
@@ -58,6 +58,10 @@ async function ensureRpcGrant(event, toolName, args) {
58
58
  if (!wc || wc.isDestroyed()) return false;
59
59
  const origin = originOf(wc);
60
60
  if (_grants.get(wc.id) === origin) return true; // already allowed for this page
61
+ let host = ""; try { host = new URL(wc.getURL()).hostname; } catch {}
62
+ let store = null; try { store = require("../profiles/trusted-origins-store"); } catch {}
63
+ if (store && host && store.isDangerousAllowed(host)) return true; // 白名单站点 + 用户选过「始终允许」
64
+ const canAlways = !!(store && host && store.listAll().includes(host));
61
65
 
62
66
  const win = BrowserWindow.fromWebContents(wc) || BrowserWindow.getFocusedWindow() || null;
63
67
  const detail = [`来源: ${origin}`, `操作: ${toolName}`];
@@ -70,7 +74,7 @@ async function ensureRpcGrant(event, toolName, args) {
70
74
  choice = await dialog.showMessageBox(win, {
71
75
  type: "warning",
72
76
  noLink: true,
73
- buttons: ["拒绝", "允许一次", "本页面内允许"],
77
+ buttons: canAlways ? ["拒绝", "允许一次", "本页面内允许", "此站点始终允许(不再询问)"] : ["拒绝", "允许一次", "本页面内允许"],
74
78
  defaultId: 0,
75
79
  cancelId: 0,
76
80
  title: "敏感操作请求",
@@ -80,6 +84,12 @@ async function ensureRpcGrant(event, toolName, args) {
80
84
  } catch { return false; }
81
85
 
82
86
  const response = choice && choice.response;
87
+ if (response === 3 && canAlways) { // persistent: allowlisted site, never ask again for dangerous tools
88
+ try { store.allowDangerous(host); } catch {}
89
+ _grants.set(wc.id, origin);
90
+ audit({ kind: "auth", gate: "dangerous-tool", origin, tool: toolName, decision: "always-allow", temporary: false, args: pv });
91
+ return true;
92
+ }
83
93
  if (response === 2) { // remember for this page
84
94
  _grants.set(wc.id, origin);
85
95
  if (!wc.__rpcGuardWired) {
@@ -104,7 +114,12 @@ async function ensureRpcGrant(event, toolName, args) {
104
114
  // trusted-origins allowlist (so it's never asked again). This replaces the old
105
115
  // "no bridge unless pre-trusted" behaviour with explicit, on-demand consent.
106
116
  const _sessionOrigins = new Set(); // origin -> "本次允许" for this process lifetime
107
- const _deniedOrigins = new Set(); // origin -> "拒绝" — sticky so a page can't spam modals
117
+ // origin -> deny timestamp. A deny is sticky only for DENY_COOLDOWN_MS so a page
118
+ // can't spam modals, but a mistaken "拒绝" (or a modal that timed out unattended)
119
+ // no longer locks the origin out for the whole process lifetime — the user gets
120
+ // asked again after the cooldown instead of having to find the settings page.
121
+ const DENY_COOLDOWN_MS = 60 * 1000;
122
+ const _deniedOrigins = new Map();
108
123
  const _pendingByOrigin = new Map(); // origin -> in-flight modal promise (dedup races)
109
124
 
110
125
  // Synchronous verdict for an origin WITHOUT prompting: "allow" | "deny" | "unknown".
@@ -119,7 +134,9 @@ function originDecision(event) {
119
134
  if (isTrustedUrl && isTrustedUrl(url)) return "allow"; // on the allowlist
120
135
  const origin = originOf(wc);
121
136
  if (_sessionOrigins.has(origin)) return "allow"; // "本次允许" earlier
122
- if (_deniedOrigins.has(origin)) return "deny"; // blocked — settings = escape hatch
137
+ const deniedAt = _deniedOrigins.get(origin);
138
+ if (deniedAt && Date.now() - deniedAt < DENY_COOLDOWN_MS) return "deny"; // recently refused
139
+ if (deniedAt) _deniedOrigins.delete(origin); // cooldown over → ask again
123
140
  return "unknown";
124
141
  }
125
142
 
@@ -165,15 +182,29 @@ function startOriginModal(event) {
165
182
  return true;
166
183
  }
167
184
  if (r === 2 && host) { // 加入白名单(持久)— trusted-origins-store.add() logs the allowlist change
168
- try {
169
- const res = require("../profiles/trusted-origins-store").add(host);
170
- if (!res || res.ok === false) return false;
185
+ let res = null, err = "";
186
+ try { res = require("../profiles/trusted-origins-store").add(host); } catch (e) { err = e && e.message; }
187
+ if (res && res.ok !== false) {
171
188
  if (refreshTrustedOrigins) refreshTrustedOrigins();
172
189
  _sessionOrigins.add(origin);
173
190
  return true;
174
- } catch { return false; }
191
+ }
192
+ // The user explicitly chose to trust the site; if persisting the allowlist
193
+ // failed, honour the intent for this run and SAY why — silently returning
194
+ // false here made the modal reappear on every call with no explanation.
195
+ _sessionOrigins.add(origin);
196
+ audit({ kind: "auth", gate: "origin", origin, decision: "session-allow", temporary: true, error: `allowlist-add-failed: ${(res && res.error) || err || "unknown"}` });
197
+ try {
198
+ dialog.showMessageBox(win, {
199
+ type: "error", noLink: true, buttons: ["知道了"],
200
+ title: "加入白名单失败",
201
+ message: `无法把 ${host} 加入受信任站点`,
202
+ detail: `${(res && res.error) || err || "未知错误"}\n\n本次已允许该站点;下次启动会再次询问。你也可以在 头像 → 受信任站点 里手动添加。`,
203
+ }).catch(() => {});
204
+ } catch {}
205
+ return true;
175
206
  }
176
- _deniedOrigins.add(origin); // 拒绝 / 关闭 — sticky for the session
207
+ _deniedOrigins.set(origin, Date.now()); // 拒绝 / 关闭 — sticky for DENY_COOLDOWN_MS
177
208
  audit({ kind: "auth", gate: "origin", origin, decision: "deny", temporary: true });
178
209
  return false;
179
210
  })();
@@ -190,4 +221,4 @@ async function ensureOriginAuthorized(event) {
190
221
  return await startOriginModal(event);
191
222
  }
192
223
 
193
- module.exports = { DANGEROUS_TOOLS, isDangerousTool, ensureRpcGrant, ensureOriginAuthorized, originDecision, startOriginModal };
224
+ module.exports = { DENY_COOLDOWN_MS, DANGEROUS_TOOLS, isDangerousTool, ensureRpcGrant, ensureOriginAuthorized, originDecision, startOriginModal };
@@ -0,0 +1,53 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+
7
+ const read = (p) => fs.readFileSync(path.join(__dirname, "..", p), "utf8");
8
+
9
+ test("ensureWsl explains a disabled-virtualization host in the log and the card", () => {
10
+ const src = read("src/sidecar/docker.js");
11
+ assert.match(src, /async function virtualizationStatus\(\)/);
12
+ const ensure = src.slice(src.indexOf("async function ensureWsl"), src.indexOf("async function bootstrap"));
13
+ assert.match(ensure, /const virt = await virtualizationStatus\(\);/);
14
+ assert.match(ensure, /reason: "virtualization_disabled"/);
15
+ assert.match(ensure, /log\.error\(`\[bootstrap\] ✗ ensure-wsl reason=virtualization_disabled/);
16
+ assert.match(ensure, /BIOS/);
17
+ assert.match(ensure, /reason: "wsl_enable_failed", message/);
18
+ assert.match(read("src/sidecar/wsl-docker.js"), /const reason = w\.reason \|\| "wsl_enable_failed"; fail\(reason, w\.message\)/);
19
+ const ipc = read("src/backends/sidecar-ipc.js");
20
+ assert.match(ipc, /lastError: \(!s\.running && _lastBootstrapError\)/);
21
+ assert.match(ipc, /_lastBootstrapError = \{ reason: result\.reason \|\| result\.error/);
22
+ });
23
+
24
+ test("trusted-origins: persistent dangerous-ops allow only for allowlisted hosts", () => {
25
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "cicy-trust-"));
26
+ const prevHome = process.env.HOME, prevUp = process.env.USERPROFILE;
27
+ process.env.HOME = home; process.env.USERPROFILE = home;
28
+ delete require.cache[require.resolve("../src/profiles/trusted-origins-store")];
29
+ try {
30
+ const store = require("../src/profiles/trusted-origins-store");
31
+ assert.equal(store.allowDangerous("xs_master.cicy-ai.com").ok, false); // not allowlisted yet
32
+ assert.equal(store.allowDangerous("127.0.0.1").ok, true); // built-in host works too
33
+ assert.equal(store.isDangerousAllowed("127.0.0.1"), true);
34
+ assert.equal(store.add("xs_master.cicy-ai.com").ok, true);
35
+ assert.equal(store.allowDangerous("https://xs_master.cicy-ai.com").ok, true);
36
+ assert.equal(store.isDangerousAllowed("xs_master.cicy-ai.com"), true);
37
+ assert.equal(store.add("other.example.com").ok, true); // adding another host keeps the flag
38
+ assert.equal(store.isDangerousAllowed("xs_master.cicy-ai.com"), true);
39
+ store.remove("xs_master.cicy-ai.com"); // removal revokes it
40
+ assert.equal(store.isDangerousAllowed("xs_master.cicy-ai.com"), false);
41
+ } finally {
42
+ process.env.HOME = prevHome; if (prevUp === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prevUp;
43
+ delete require.cache[require.resolve("../src/profiles/trusted-origins-store")];
44
+ fs.rmSync(home, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ test("rpc-guard offers a persistent 'always allow' only for allowlisted origins", () => {
49
+ const src = read("src/utils/rpc-guard.js");
50
+ assert.match(src, /store\.isDangerousAllowed\(host\)\) return true/);
51
+ assert.match(src, /canAlways \? \["拒绝", "允许一次", "本页面内允许", "此站点始终允许(不再询问)"\]/);
52
+ assert.match(src, /response === 3 && canAlways/);
53
+ });
@@ -0,0 +1,17 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const { updateGlobalConfig, readGlobalConfig } = require("../src/utils/global-json");
7
+
8
+ test("updateGlobalConfig works when ~/cicy-ai does not exist yet (fresh machine)", () => {
9
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "cicy-fresh-"));
10
+ const file = path.join(root, "cicy-ai", "global.json"); // parent dir missing on purpose
11
+ try {
12
+ const next = updateGlobalConfig(file, (c) => ({ ...c, api_token: "cicy_test" }));
13
+ assert.equal(next.api_token, "cicy_test");
14
+ assert.equal(readGlobalConfig(file).api_token, "cicy_test");
15
+ assert.equal(fs.existsSync(`${file}.lock`), false);
16
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
17
+ });
@@ -0,0 +1,18 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ // Static guard: openTeam must NOT use the host global.json token for the
7
+ // Windows Docker :8008 team — that token belongs to the container's own volume.
8
+ test("openTeam reads the container token for the Windows Docker :8008 team, never the host global.json", () => {
9
+ const src = fs.readFileSync(path.join(__dirname, "..", "src", "backends", "local-teams.js"), "utf8");
10
+ const open = src.slice(src.indexOf("async function openTeam"), src.indexOf("async function openTeam") + 2000);
11
+ // win32 + local + (is_docker | docker port) → container token
12
+ assert.match(open, /const isWinDockerTeam = process\.platform === "win32" && isLocalUrl/);
13
+ assert.match(open, /readContainerToken\(localPort\)/);
14
+ // when it's the win docker team, the host global.json fallback must be gated OUT
15
+ assert.match(open, /if \(isWinDockerTeam\)[^]*?\} else if \(isLocalUrl\) \{\s*token = readGlobal\(\)\?\.api_token/);
16
+ // and a missing container token refuses to open (no silent host-token page)
17
+ assert.match(open, /container_token_unavailable/);
18
+ });
@@ -0,0 +1,27 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const os = require("node:os");
6
+
7
+ test("setNote persists a trimmed note on electron profiles", () => {
8
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "cicy-note-"));
9
+ const prevHome = process.env.HOME, prevUp = process.env.USERPROFILE;
10
+ process.env.HOME = home; process.env.USERPROFILE = home;
11
+ delete require.cache[require.resolve("../src/profiles/profile-store")];
12
+ try {
13
+ const store = require("../src/profiles/profile-store");
14
+ fs.mkdirSync(store.ELECTRON_DIR, { recursive: true });
15
+ const view = store.setNote("electron", 3, " 主号,别乱动 ");
16
+ assert.equal(view.note, "主号,别乱动");
17
+ assert.equal(store.getProfile("electron", 3).note, "主号,别乱动");
18
+ assert.equal(store.setNote("electron", 3, "").note, "");
19
+ assert.equal(store.removeProfile("electron", 3), true);
20
+ assert.equal(store.getProfile("electron", 3), null);
21
+ assert.equal(store.removeProfile("electron", 3), false);
22
+ } finally {
23
+ process.env.HOME = prevHome; if (prevUp === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prevUp;
24
+ delete require.cache[require.resolve("../src/profiles/profile-store")];
25
+ fs.rmSync(home, { recursive: true, force: true });
26
+ }
27
+ });
@@ -0,0 +1,18 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ // Static checks: a deny must expire (cooldown) and a failed allowlist add must
7
+ // still honour the user's "trust" choice for the session and explain the error.
8
+ test("origin deny is a cooldown, not process-lifetime; failed allowlist add is explained", () => {
9
+ const src = fs.readFileSync(path.join(__dirname, "..", "src", "utils", "rpc-guard.js"), "utf8");
10
+ assert.match(src, /const DENY_COOLDOWN_MS = \d+ \* 1000;/);
11
+ assert.match(src, /const _deniedOrigins = new Map\(\)/);
12
+ assert.match(src, /Date\.now\(\) - deniedAt < DENY_COOLDOWN_MS\) return "deny"/);
13
+ assert.match(src, /_deniedOrigins\.set\(origin, Date\.now\(\)\)/);
14
+ assert.match(src, /加入白名单失败/);
15
+ const trustBranch = src.slice(src.indexOf("if (r === 2 && host)"), src.indexOf("_deniedOrigins.set(origin"));
16
+ assert.match(trustBranch, /_sessionOrigins\.add\(origin\);[^]*return true;[^]*_sessionOrigins\.add\(origin\);[^]*return true;/);
17
+ assert.doesNotMatch(trustBranch, /return false/);
18
+ });
@@ -0,0 +1,41 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const vm = require("node:vm");
4
+ const {
5
+ isTelegramUrl,
6
+ TELEGRAM_IDENTITY_SCRIPT,
7
+ normalizeTelegramIdentity,
8
+ telegramLoginRecord,
9
+ telegramIdentityFromProfile,
10
+ } = require("../src/tabbrowser/telegram-identity");
11
+
12
+ test("recognizes telegram web urls only", () => {
13
+ assert.equal(isTelegramUrl("https://web.telegram.org/k/"), true);
14
+ assert.equal(isTelegramUrl("https://web.telegram.org/a/#123"), true);
15
+ assert.equal(isTelegramUrl("https://example.com/web.telegram.org"), false);
16
+ assert.equal(isTelegramUrl("chrome-error://chromewebdata/"), false);
17
+ });
18
+
19
+ test("identity script is valid JS and returns null without storage", async () => {
20
+ new vm.Script(TELEGRAM_IDENTITY_SCRIPT); // syntax
21
+ const ctx = vm.createContext({ localStorage: { getItem: () => null }, indexedDB: {} });
22
+ assert.equal(await vm.runInContext(TELEGRAM_IDENTITY_SCRIPT, ctx), null);
23
+ });
24
+
25
+ test("identity script never leaks auth material", () => {
26
+ assert.doesNotMatch(TELEGRAM_IDENTITY_SCRIPT, /auth_key|server_salt|account\d/);
27
+ });
28
+
29
+ test("normalizes identity and maps to a profile login record", () => {
30
+ const it = normalizeTelegramIdentity({ id: 42, username: "@alice", firstName: " Alice ", lastName: "L", phone: "1234" });
31
+ assert.deepEqual(it, { id: "42", username: "alice", displayName: "Alice L", phone: "1234" });
32
+ assert.deepEqual(telegramLoginRecord(it), { url: "https://web.telegram.org", name: "telegram", username: "alice", mobile: "1234", note: "Alice L" });
33
+ assert.equal(normalizeTelegramIdentity(null), null);
34
+ assert.equal(normalizeTelegramIdentity({ username: "x" }), null);
35
+ });
36
+
37
+ test("reads identity back from a stored profile", () => {
38
+ const p = { logins: [{ name: "gmail", username: "a@b" }, { name: "Telegram", username: "alice", mobile: "1", note: "Alice" }] };
39
+ assert.deepEqual(telegramIdentityFromProfile(p), { username: "alice", displayName: "Alice", phone: "1" });
40
+ assert.equal(telegramIdentityFromProfile({ logins: [] }), null);
41
+ });
@@ -58,7 +58,7 @@ test("a cached-but-offline cell is reported as failed, not loaded", () => {
58
58
  // service worker 会让断网的 Telegram 也「加载成功」,只能从 session 层探。
59
59
  assert.match(src, /net\.fetch\(target, \{[^]*session: session\.fromPartition\(partitionFor\(profileIdx\)\)/);
60
60
  assert.match(src, /ERR_NO_NETWORK/);
61
- assert.match(src, /did-stop-loading", \(\) => \{ push\(\); probeReachable\(\); \}/);
61
+ assert.match(src, /did-stop-loading", \(\) => \{ push\(\); probeReachable\(\);/);
62
62
  assert.match(src, /if \(seq !== probeSeq\) return;/);
63
63
  // 一次失败不判定,避免代理抖动误报
64
64
  const probe = src.slice(src.indexOf("const probeReachable"), src.indexOf("wc.on(\"did-start-loading\""));
@@ -73,25 +73,31 @@ test("panel reload adopts existing session views instead of destroying them", ()
73
73
  assert.match(pull, /if \(adopt\)[^]*openedProfiles\.add\(Number\(m\[1\]\)\)/);
74
74
  });
75
75
 
76
- test("saving a proxy never leaves an unopened profile stuck at loading", () => {
76
+ test("reloading an unopened profile creates its cell instead of a bare reload", () => {
77
77
  const html = fs.readFileSync(path.join(__dirname, "..", "src", "tabbrowser", "telegram-matrix.html"), "utf8");
78
- const save = html.slice(html.indexOf("const save = async"), html.indexOf("row.onclick ="));
79
- // 只有已打开的会话才置 loading / 重载;未打开的没有 cell,置了就永远回不来。
80
- assert.match(save, /openedProfiles\.has\(p\.accountIdx\)/);
81
- assert.doesNotMatch(save, /cellState\.set\(p\.accountIdx, \{ loading: true \}\)/);
82
78
  const reload = html.slice(html.indexOf("function reloadProfile"), html.indexOf("function render()"));
83
79
  assert.match(reload, /const fresh = !openedProfiles\.has\(idx\)/);
84
80
  assert.match(reload, /if \(!fresh\)[^]*panelAPI\.reload/);
85
81
  });
86
82
 
87
- test("matrix page exposes profile table, proxy editor, and phone preview", () => {
83
+ test("matrix page exposes profile table, phone preview, note, proxy drawer and ip probe", () => {
88
84
  const html = fs.readFileSync(path.join(__dirname, "..", "src", "tabbrowser", "telegram-matrix.html"), "utf8");
89
85
  assert.match(html, /id="rows"/);
90
86
  assert.match(html, /id="ov-error"/);
91
87
  assert.match(html, /panelAPI\.states/);
92
88
  assert.match(html, /panelAPI\.setProfileProxy/);
89
+ assert.match(html, /panelAPI\.probeIp/);
90
+ assert.match(html, /panelAPI\.removeProfile/);
91
+ // 删除必须二次确认:确认块默认隐藏,只有 rm-yes 才真正调用删除
92
+ assert.match(html, /\.cfg \.confirm \{ display: none;/);
93
+ assert.match(html, /querySelector\('\.rm-yes'\)\.onclick = async[^]*await removeProfile\(p\)/);
94
+ assert.doesNotMatch(html.slice(html.indexOf("querySelector('.rm').onclick"), html.indexOf("querySelector('.rm-no')")), /removeProfile\(/);
93
95
  assert.match(html, /id="add-profile"/);
94
- assert.match(html, /data-role="proxy"/);
96
+ assert.match(html, /class="cfg"[^]*data-role="proxy"/);
97
+ assert.match(html, /\.row\.cfg-open \.cfg \{ display: block; \}/);
98
+ assert.match(html, /panelAPI\.setProfileNote/);
99
+ assert.match(html, /data-role="note"/);
100
+ assert.match(html, /class="tg none"/);
95
101
  assert.match(html, /id="phone-preview"/);
96
102
  assert.match(html, /https:\/\/web\.telegram\.org\/k\//);
97
103
  assert.match(html, /panelAPI\.sync/);
@@ -0,0 +1,11 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { normalizeHost } = require("../src/profiles/trusted-origins-store");
4
+
5
+ test("trusted-origins accepts hostnames with underscores, bare or as URL", () => {
6
+ assert.equal(normalizeHost("xs_master.cicy-ai.com"), "xs_master.cicy-ai.com");
7
+ assert.equal(normalizeHost("https://XS_master.cicy-ai.com/path?q=1"), "xs_master.cicy-ai.com");
8
+ assert.equal(normalizeHost("xs_master.cicy-ai.com:8008"), "xs_master.cicy-ai.com");
9
+ assert.equal(normalizeHost("bad host"), "");
10
+ assert.equal(normalizeHost(".x.com"), "");
11
+ });