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.
@@ -6,7 +6,7 @@
6
6
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
7
7
  <link rel="icon" type="image/png" sizes="256x256" href="./favicon-256.png" />
8
8
  <title>CiCy Desktop</title>
9
- <script type="module" crossorigin src="./assets/index-Bok65e-y.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-BzYBKzQW.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-C6Zrdgc0.css">
11
11
  </head>
12
12
  <body>
@@ -308,10 +308,24 @@ async function openTeam(id, opts = {}) {
308
308
  // 共用同一份)—— 打开时**实时读 global.json**,cicy-code 轮换 token 也立刻跟得上,绝不吃 teams.json
309
309
  // 里可能已陈旧的快照(陈旧 → ?token= 旧值 → :8008 拒 → 卡登录/白屏)。opts.token(如 :8008 容器
310
310
  // 自己实时拿的)优先级最高;非本地团队仍用存的 node.api_token。
311
- let isLocalUrl = false;
312
- try { const h = new URL(baseUrl).hostname; isLocalUrl = h === "127.0.0.1" || h === "localhost" || h === "::1"; } catch {}
313
- const token = (opts && opts.token)
314
- || (isLocalUrl ? (readGlobal()?.api_token || node.api_token || "") : (node.api_token || ""));
311
+ let isLocalUrl = false, localPort = null;
312
+ try { const u = new URL(baseUrl); isLocalUrl = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "::1"; localPort = Number(u.port) || 8008; } catch {}
313
+ // Windows 的 :8008 是 WSL Docker 容器,token 存在**容器卷里的 global.json**,和主机
314
+ // ~/cicy-ai/global.json 是两把不同的 token —— 绝不能 fallback 到主机的那把(会被容器拒,
315
+ // 卡在登录/白屏)。只有 mac/linux 的 native :8008 才与主机共用同一份 global.json。
316
+ const isWinDockerTeam = process.platform === "win32" && isLocalUrl
317
+ && (node.is_docker === true || localPort === Number(process.env.CICY_DOCKER_APP_PORT || 8008));
318
+ let token = (opts && opts.token) || "";
319
+ if (!token) {
320
+ if (isWinDockerTeam) {
321
+ try { token = String(await require("../sidecar/wsl-docker").readContainerToken(localPort) || "").trim(); } catch (e) { log.warn(`[local-teams] open ${id}: container token read failed: ${e.message}`); }
322
+ if (!token) return { ok: false, error: "container_token_unavailable", hint: "cicy-code 容器还没就绪(读不到容器内的 token)。稍等几秒或用卡片菜单「重启」。" };
323
+ } else if (isLocalUrl) {
324
+ token = readGlobal()?.api_token || node.api_token || "";
325
+ } else {
326
+ token = node.api_token || "";
327
+ }
328
+ }
315
329
  const url = token ? `${baseUrl}/?token=${encodeURIComponent(token)}` : baseUrl;
316
330
 
317
331
  // Compare by origin+pathname only — token + hash both vary per
@@ -139,6 +139,9 @@ function register({ sidecarLogPath } = {}) {
139
139
  // caches it (memory + file), AND auto-starts whatever is installed-but-down. The
140
140
  // docker:app-status handler just returns the cache — instant, never blocks.
141
141
  let _dockerStatusCache = null;
142
+ // 最近一次安装/启动失败的原因(reason + 人话 message),写进 docker-status.json 的
143
+ // lastError,卡片和排障都能看到"为什么装不上",而不是只看到 installed:false。
144
+ let _lastBootstrapError = null;
142
145
  let _dockerDaemonBusy = false;
143
146
  let _gatewayKeyMissingChecks = 0;
144
147
  let _autostartEnsured = false;
@@ -150,7 +153,7 @@ function register({ sidecarLogPath } = {}) {
150
153
  if (s.running) { try { ver = await require("../sidecar/version").running(APP_PORT); } catch {} }
151
154
  // installed: distro 装了 OR :8008 健康(WSL 抽风查不到 distro 但容器在跑 → 也算装了,
152
155
  // 否则卡片误显「下载安装」)。wslUnmanaged: 服务在跑但 WSL 管不到 → 卡片显式提示异常。
153
- _dockerStatusCache = { installed: !!s.distro || !!s.healthy, dockerRunning: !!s.engineUp || !!s.healthy, running: !!s.running, unknown: !!s.unknown, wslUnmanaged: !!s.wslUnmanaged, wslWedged: !!s.wslWedged, version: ver, port: APP_PORT, platform: process.platform, chromeProxy: chromeProxyEnabled(), chromeProxyRunning: hostMihomo.running(), ts: Date.now() };
156
+ _dockerStatusCache = { installed: !!s.distro || !!s.healthy, dockerRunning: !!s.engineUp || !!s.healthy, running: !!s.running, unknown: !!s.unknown, wslUnmanaged: !!s.wslUnmanaged, wslWedged: !!s.wslWedged, version: ver, port: APP_PORT, platform: process.platform, chromeProxy: chromeProxyEnabled(), chromeProxyRunning: hostMihomo.running(), lastError: (!s.running && _lastBootstrapError) ? _lastBootstrapError : null, ts: Date.now() };
154
157
  } catch (e) {
155
158
  _dockerStatusCache = { installed: false, dockerRunning: false, running: false, unknown: true, port: APP_PORT, platform: process.platform, error: e.message, ts: Date.now() };
156
159
  }
@@ -285,6 +288,11 @@ function register({ sidecarLogPath } = {}) {
285
288
  port: PORT,
286
289
  onProgress: (ev) => { try { e.sender.send("docker:bootstrap-progress", ev); } catch {} },
287
290
  });
291
+ if (result && result.ok) _lastBootstrapError = null;
292
+ else if (result) {
293
+ _lastBootstrapError = { reason: result.reason || result.error || "bootstrap_failed", message: result.message || "", ts: Date.now() };
294
+ log.error(`[docker] bootstrap failed reason=${_lastBootstrapError.reason}${_lastBootstrapError.message ? ` — ${_lastBootstrapError.message}` : ""}`);
295
+ }
288
296
  // Healthy local stack → make sure it shows up as a team ("本地团队就加
289
297
  // 上去了"). addTeam dedups by host:port, so re-runs are no-ops. The
290
298
  // api_token must be the CONTAINER's own (volume global.json) — the
@@ -301,6 +309,8 @@ function register({ sidecarLogPath } = {}) {
301
309
  }
302
310
  return result;
303
311
  } catch (err) {
312
+ _lastBootstrapError = { reason: "bootstrap_exception", message: err.message, ts: Date.now() };
313
+ log.error(`[docker] bootstrap threw: ${err.message}`);
304
314
  return { ok: false, error: err.message };
305
315
  }
306
316
  });
package/src/main.js CHANGED
@@ -168,8 +168,12 @@ if (process.platform === "win32") {
168
168
  const DEEPLINK_SCHEMES = ["cicy-desktop", "cicy"]; // primary, then legacy
169
169
  const isDeepLink = (u) =>
170
170
  typeof u === "string" && DEEPLINK_SCHEMES.some((s) => u.startsWith(`${s}://`));
171
+ // Source/dev runs (`electron <appdir>`) must register execPath + the app dir,
172
+ // otherwise Windows stores a bare `electron.exe "%1"` handler that launches an
173
+ // empty Electron shell — and clobbers the installed app's registration.
174
+ const deeplinkArgs = electronApp.isPackaged ? [] : [process.execPath, [path.resolve(process.argv[1] || electronApp.getAppPath())]];
171
175
  for (const s of DEEPLINK_SCHEMES) {
172
- try { if (!electronApp.isDefaultProtocolClient(s)) electronApp.setAsDefaultProtocolClient(s); } catch {}
176
+ try { if (!electronApp.isDefaultProtocolClient(s, ...deeplinkArgs)) electronApp.setAsDefaultProtocolClient(s, ...deeplinkArgs); } catch {}
173
177
  }
174
178
 
175
179
  // Deep links can arrive before any BrowserWindow exists (cold start via
@@ -634,7 +638,7 @@ ipcMain.handle("rpc:guarded", async (event, toolName, args) => {
634
638
  const originOk = await ensureOriginAuthorized(event);
635
639
  if (!originOk) {
636
640
  audit({ kind: "rpc", channel: "rpc:guarded", origin, tool: toolName, dangerous: danger, ok: false, error: "origin-unauthorized", args: argsPreview(toolName, args) });
637
- throw new Error(`未授权站点访问桌面 RPC(rpc:guarded:域名未加入白名单)`);
641
+ throw new Error(`未授权站点访问桌面 RPC(${origin} 未加入白名单或刚被拒绝;请在 CiCy Desktop 头像 → 受信任站点 中添加该域名,或稍后重试并在弹框中选择允许)`);
638
642
  }
639
643
  }
640
644
  if (danger) {
@@ -305,6 +305,33 @@ function setProxy(backend, idx, url) {
305
305
  throw new Error(`Unknown backend: ${backend}`);
306
306
  }
307
307
 
308
+ // removeProfile — drop the profile record. Electron: deletes account-<N>.json
309
+ // (the caller is responsible for the session's storage). Returns true if it existed.
310
+ function removeProfile(backend, idx) {
311
+ if (backend === "electron") {
312
+ const f = electronFile(idx);
313
+ if (!fs.existsSync(f)) return false;
314
+ fs.unlinkSync(f);
315
+ return true;
316
+ }
317
+ if (backend === "chrome") {
318
+ const cfg = readChromeConfig();
319
+ if (!cfg[`profile_${idx}`]) return false;
320
+ delete cfg[`profile_${idx}`];
321
+ fs.writeFileSync(CHROME_JSON, JSON.stringify(cfg, null, 2));
322
+ return true;
323
+ }
324
+ throw new Error(`Unknown backend: ${backend}`);
325
+ }
326
+
327
+ // setNote — free-form per-profile note (both backends).
328
+ function setNote(backend, idx, note) {
329
+ const text = typeof note === "string" ? note.trim() : "";
330
+ if (backend === "chrome") return mutateChrome(idx, (e) => ({ ...e, note: text }));
331
+ if (backend === "electron") return mutateElectron(idx, (d) => ({ ...d, note: text }));
332
+ throw new Error(`Unknown backend: ${backend}`);
333
+ }
334
+
308
335
  // setLogin — upsert a rich login record (any subset of LOGIN_FIELDS). Keyed by
309
336
  // `name` (site name). Works identically for both backends.
310
337
  function setLogin(backend, idx, login) {
@@ -345,6 +372,8 @@ module.exports = {
345
372
  listProfiles,
346
373
  getProfile,
347
374
  setProxy,
375
+ setNote,
376
+ removeProfile,
348
377
  setLogin,
349
378
  addLogin,
350
379
  removeLogin,
@@ -35,7 +35,7 @@ function normalizeHost(input) {
35
35
  }
36
36
  s = s.split("/")[0].split("?")[0].split("#")[0]; // drop path/query/fragment
37
37
  s = s.replace(/:\d+$/, ""); // drop :port
38
- if (!/^[a-z0-9.-]+$/.test(s)) return ""; // basic host charset
38
+ if (!/^[a-z0-9._-]+$/.test(s)) return ""; // basic host charset (underscore is legal in real-world hostnames, e.g. xs_master.example.com)
39
39
  if (s.startsWith(".") || s.endsWith(".") || s.includes("..")) return "";
40
40
  return s;
41
41
  }
@@ -48,11 +48,41 @@ function readRaw() {
48
48
  } catch { return []; }
49
49
  }
50
50
 
51
- function writeRaw(origins) {
51
+ function writeRaw(origins, dangerous) {
52
52
  fs.mkdirSync(path.dirname(STORE), { recursive: true });
53
- fs.writeFileSync(STORE, JSON.stringify({ origins }, null, 2), { mode: 0o600 });
53
+ const cur = readRawObj();
54
+ const next = { origins, dangerous: Array.isArray(dangerous) ? dangerous : (Array.isArray(cur.dangerous) ? cur.dangerous : []) };
55
+ fs.writeFileSync(STORE, JSON.stringify(next, null, 2), { mode: 0o600 });
54
56
  try { fs.chmodSync(STORE, 0o600); } catch {}
55
57
  }
58
+ function readRawObj() {
59
+ try { if (!fs.existsSync(STORE)) return {}; const j = JSON.parse(fs.readFileSync(STORE, "utf8")); return j && typeof j === "object" ? j : {}; } catch { return {}; }
60
+ }
61
+
62
+ // "此站点始终允许敏感操作":对已在白名单里的站点,持久跳过 exec/读写文件的逐次确认。
63
+ // 只有白名单站点可以加入(不在白名单 → 拒绝),从白名单移除时一并撤销。
64
+ function listDangerousAllowed() {
65
+ const set = new Set(listAll()); // built-ins (localhost/127.0.0.1) included — the local team page IS 127.0.0.1
66
+ return (Array.isArray(readRawObj().dangerous) ? readRawObj().dangerous : []).map(normalizeHost).filter((h) => h && set.has(h));
67
+ }
68
+ function isDangerousAllowed(host) {
69
+ const h = normalizeHost(host);
70
+ return !!h && listDangerousAllowed().includes(h);
71
+ }
72
+ function allowDangerous(input) {
73
+ const host = normalizeHost(input);
74
+ if (!host) return { ok: false, error: "无效的站点地址" };
75
+ if (!listAll().includes(host)) return { ok: false, error: "站点不在白名单中" };
76
+ const cur = listDangerousAllowed();
77
+ if (!cur.includes(host)) { writeRaw(listUser(), [...cur, host]); _audit({ kind: "auth", gate: "allowlist", host, decision: "dangerous-always-allow" }); }
78
+ return { ok: true };
79
+ }
80
+ function revokeDangerous(input) {
81
+ const host = normalizeHost(input);
82
+ const cur = listDangerousAllowed();
83
+ if (cur.includes(host)) { writeRaw(listUser(), cur.filter((h) => h !== host)); _audit({ kind: "auth", gate: "allowlist", host, decision: "dangerous-revoke" }); }
84
+ return { ok: true };
85
+ }
56
86
 
57
87
  // User-managed origins only (normalized, de-duped, built-ins excluded).
58
88
  function listUser() {
@@ -91,8 +121,8 @@ function remove(input) {
91
121
  const host = normalizeHost(input);
92
122
  if (BUILTIN.includes(host)) return { ok: false, error: "内置站点不可删除" };
93
123
  const cur = listUser();
94
- if (cur.includes(host)) { writeRaw(cur.filter((h) => h !== host)); _audit({ kind: "auth", gate: "allowlist", host, decision: "trust-remove" }); }
124
+ if (cur.includes(host)) { writeRaw(cur.filter((h) => h !== host), listDangerousAllowed().filter((h) => h !== host)); _audit({ kind: "auth", gate: "allowlist", host, decision: "trust-remove" }); }
95
125
  return { ok: true, origins: listForUi() };
96
126
  }
97
127
 
98
- module.exports = { STORE, BUILTIN, normalizeHost, listUser, listAll, listForUi, add, remove };
128
+ module.exports = { STORE, BUILTIN, normalizeHost, listUser, listAll, listForUi, add, remove, listDangerousAllowed, isDangerousAllowed, allowDangerous, revokeDangerous };
@@ -11,6 +11,7 @@
11
11
  // https://r2.deepfetch.de5.net/docker/cicy-code-latest.tar.gz
12
12
  //
13
13
  // The container maps :8008 and persists ~/cicy-ai in a named volume.
14
+ const log = require("electron-log"); // bootstrap preflight failures must land in main.log
14
15
  const { execFile, execFileSync, spawn } = require("child_process");
15
16
  const https = require("https");
16
17
  const http = require("http");
@@ -660,7 +661,36 @@ async function dismEnableFeature(feature, label, { emit } = {}) {
660
661
  // { ok } when already present, { needsReboot } after the two required Windows
661
662
  // features are verified-enabled (a Windows reboot is then needed before Docker
662
663
  // can use WSL2), or { failed } if a feature couldn't be enabled.
664
+ // CPU virtualization state (Windows). WSL2 cannot start when the firmware has
665
+ // VT-x/AMD-V disabled — and nothing in software can flip that. Detect it up
666
+ // front so the failure is EXPLAINED (log + card) instead of a mute install loop.
667
+ // hypervisorPresent=true means a hypervisor already runs (Hyper-V/WSL2 active)
668
+ // → virtualization is necessarily on, regardless of what the CPU field says.
669
+ async function virtualizationStatus() {
670
+ if (process.platform !== "win32") return { known: false };
671
+ return new Promise((resolve) => {
672
+ const ps = "$cs=Get-CimInstance Win32_ComputerSystem; $cpu=Get-CimInstance Win32_Processor | Select-Object -First 1; Write-Output (\"\" + $cs.HypervisorPresent + \"|\" + $cpu.VirtualizationFirmwareEnabled + \"|\" + $cpu.VMMonitorModeExtensions + \"|\" + $cpu.Name)";
673
+ execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 20000, windowsHide: true }, (err, stdout) => {
674
+ const line = String(stdout || "").trim().split(/\r?\n/).pop() || "";
675
+ const [hv, fw, vmm, ...rest] = line.split("|");
676
+ if (err || !line || line.split("|").length < 3) return resolve({ known: false, raw: line, error: err && err.message });
677
+ const b = (v) => /^true$/i.test(String(v).trim());
678
+ const hypervisorPresent = b(hv), firmwareEnabled = b(fw), cpuSupports = b(vmm);
679
+ resolve({ known: true, hypervisorPresent, firmwareEnabled, cpuSupports, cpu: rest.join("|").trim(), ok: hypervisorPresent || firmwareEnabled });
680
+ });
681
+ });
682
+ }
683
+
663
684
  async function ensureWsl({ emit } = {}) {
685
+ const virt = await virtualizationStatus();
686
+ if (virt.known && !virt.ok) {
687
+ const why = virt.cpuSupports
688
+ ? `CPU 虚拟化未在 BIOS/固件中开启(Virtualization Enabled In Firmware: No,CPU: ${virt.cpu || "?"})。请重启进 BIOS 打开 Intel VT-x / AMD SVM 后再点「重试」。`
689
+ : `此 CPU 不支持硬件虚拟化(VM Monitor Mode Extensions: No,CPU: ${virt.cpu || "?"}),无法运行 WSL2 / Docker。`;
690
+ log.error(`[bootstrap] ✗ ensure-wsl reason=virtualization_disabled hypervisorPresent=${virt.hypervisorPresent} firmwareEnabled=${virt.firmwareEnabled} cpuSupports=${virt.cpuSupports} cpu=${virt.cpu}`);
691
+ emit && emit({ phase: "done", status: "error", message: why });
692
+ return { ok: false, needsReboot: false, failed: true, reason: "virtualization_disabled", message: why };
693
+ }
664
694
  const executableMissing = await wslMissing();
665
695
  const subsystemEnabled = await featureEnabled("Microsoft-Windows-Subsystem-Linux");
666
696
  const vmPlatformEnabled = await featureEnabled("VirtualMachinePlatform");
@@ -671,8 +701,10 @@ async function ensureWsl({ emit } = {}) {
671
701
  const a = await dismEnableFeature("Microsoft-Windows-Subsystem-Linux", "启用 WSL 功能 1/2 · Linux 子系统", { emit });
672
702
  const b = await dismEnableFeature("VirtualMachinePlatform", "启用 WSL 功能 2/2 · 虚拟机平台", { emit });
673
703
  if (!a || !b) {
674
- emit && emit({ phase: "done", status: "error", message: "WSL 功能未能全部启用——请点「重试」" });
675
- return { ok: false, needsReboot: false, failed: true };
704
+ const message = `WSL 功能未能全部启用(Linux 子系统=${a ? "已启用" : "失败"},虚拟机平台=${b ? "已启用" : "失败"};通常是 UAC 被取消或当前账号无管理员权限)——请以管理员身份重试。`;
705
+ log.error(`[bootstrap] ✗ ensure-wsl reason=wsl_enable_failed subsystem=${a} vmPlatform=${b}`);
706
+ emit && emit({ phase: "done", status: "error", message });
707
+ return { ok: false, needsReboot: false, failed: true, reason: "wsl_enable_failed", message };
676
708
  }
677
709
  // Best-effort: also pull the WSL2 kernel/plumbing when the executable itself
678
710
  // is missing. Feature-only repairs must stop here and wait for reboot.
@@ -779,7 +811,7 @@ module.exports = {
779
811
  start, stop, stopContainer, restart, checkStatus, loadImage, loadImageFromTarball,
780
812
  downloadImageTarball, imagePresent, dockerOk, installDocker,
781
813
  bootstrap, probeHealth, readContainerToken, dockerDesktopExe, desktopDir, downloadsDir, imageTarballPath,
782
- launchElevated, wslMissing, ensureWsl,
814
+ launchElevated, wslMissing, ensureWsl, virtualizationStatus,
783
815
  // platform-agnostic download/retry primitives, reused by native.js
784
816
  ensureDownloaded, curlDownload, withRetry, waitUntil, run, headSize,
785
817
  // image freshness (修「重建仍用旧镜像」—— 校验 OSS ETag 变了才重下重载)
@@ -1162,7 +1162,7 @@ async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-dock
1162
1162
  begin("ensure-wsl");
1163
1163
  const w = await docker.ensureWsl({ emit });
1164
1164
  if (w.needsReboot) { fail("wsl_reboot_required"); emit({ phase: "done", status: "reboot", message: "WSL2 所需功能已启用——请【重启 Windows】后回来点「重试」继续。" }); finish(false, "wsl_reboot_required"); return { ok: false, reason: "wsl_reboot_required" }; }
1165
- if (!w.ok) { fail("wsl_enable_failed"); finish(false, "wsl_enable_failed"); return { ok: false, reason: "wsl_enable_failed" }; }
1165
+ if (!w.ok) { const reason = w.reason || "wsl_enable_failed"; fail(reason, w.message); finish(false, reason); return { ok: false, reason, message: w.message || "" }; }
1166
1166
  done(true);
1167
1167
 
1168
1168
  // 2) Ubuntu distro
@@ -46,6 +46,7 @@ function profileForCell(url, requested) {
46
46
  const idx = Number(requested);
47
47
  return Number.isInteger(idx) && idx > 0 ? idx : DEFAULT_PROFILE;
48
48
  }
49
+ const telegramIdentity = require("./telegram-identity");
49
50
  const appliedProxy = new Set(); // partitions whose proxy is already configured
50
51
  function ensureCellSessionProxy(idx) {
51
52
  const part = partitionFor(idx);
@@ -117,6 +118,7 @@ class PanelCells {
117
118
  },
118
119
  });
119
120
  const wc = view.webContents;
121
+ const rec = { view, url: "", profile: profileIdx, visible: true, identity: null };
120
122
  try { view.setBackgroundColor("#ffffff"); } catch (e) {}
121
123
  // Chromium uses a dark canvas for transparent/un-styled documents when the
122
124
  // app theme is dark (for example a plain `Not Found` response). Keep real
@@ -178,7 +180,7 @@ class PanelCells {
178
180
  let loading = false;
179
181
  try { loading = wc.isLoading(); } catch (e) {}
180
182
  const failed = /^chrome-error:/.test(url) || !!lastError;
181
- this.sendState({ id: cellId, url, title, loading, wcId: wc.id, failed, error: failed ? lastError : null });
183
+ this.sendState({ id: cellId, url, title, loading, wcId: wc.id, failed, error: failed ? lastError : null, identity: rec.identity || null });
182
184
  };
183
185
  // Telegram Web 是 PWA:代理挂掉时 service worker 照样从缓存吐出 app shell,主框架
184
186
  // 「加载成功」、did-fail-load 不触发,可页面其实一点网都没有(用户看到的就是一片白
@@ -219,8 +221,38 @@ class PanelCells {
219
221
  push();
220
222
  };
221
223
 
222
- wc.on("did-start-loading", () => { probeSeq++; this.sendState({ id: cellId, loading: true, wcId: wc.id }); });
223
- wc.on("did-stop-loading", () => { push(); probeReachable(); });
224
+ // Telegram 身份:登录后从页面自己的 localStorage/IndexedDB 读出 @username,写进
225
+ // profile 的 logins(name=telegram),并推给面板列表。登录流程刚结束时用户表可能
226
+ // 还没落库,所以延迟探几次;拿到就停。
227
+ let identSeq = 0;
228
+ const detectIdentity = () => {
229
+ if (profileIdx === 0) return;
230
+ let url = ""; try { url = wc.getURL(); } catch (e) {}
231
+ if (!telegramIdentity.isTelegramUrl(url)) return;
232
+ const seq = ++identSeq;
233
+ const delays = [2500, 8000, 20000, 45000];
234
+ const tick = async (i) => {
235
+ if (seq !== identSeq || wc.isDestroyed()) return;
236
+ let raw = null;
237
+ try { raw = await wc.executeJavaScript(telegramIdentity.TELEGRAM_IDENTITY_SCRIPT, true); } catch (e) {}
238
+ const it = telegramIdentity.normalizeTelegramIdentity(raw);
239
+ if (seq !== identSeq) return;
240
+ if (it && (it.username || it.displayName || it.phone)) {
241
+ const prev = rec.identity;
242
+ rec.identity = it;
243
+ if (!prev || prev.username !== it.username || prev.displayName !== it.displayName || prev.phone !== it.phone) {
244
+ try { require("../profiles/profile-store").setLogin("electron", profileIdx, telegramIdentity.telegramLoginRecord(it)); } catch (e) {}
245
+ this.sendState({ id: cellId, wcId: wc.id, identity: it, loading: false, url, title: (() => { try { return wc.getTitle(); } catch (e) { return ""; } })() });
246
+ }
247
+ return;
248
+ }
249
+ if (i + 1 < delays.length) setTimeout(() => tick(i + 1), delays[i + 1]);
250
+ };
251
+ setTimeout(() => tick(0), delays[0]);
252
+ };
253
+
254
+ wc.on("did-start-loading", () => { probeSeq++; identSeq++; this.sendState({ id: cellId, loading: true, wcId: wc.id }); });
255
+ wc.on("did-stop-loading", () => { push(); probeReachable(); detectIdentity(); });
224
256
  wc.on("did-fail-load", (_e, errorCode, errorDescription, validatedURL, isMainFrame) => {
225
257
  if (isMainFrame === false || errorCode === -3) return; // 子框架 / ERR_ABORTED(用户导航打断)不算失败
226
258
  lastError = { code: errorCode, description: String(errorDescription || ""), url: String(validatedURL || "") };
@@ -232,7 +264,7 @@ class PanelCells {
232
264
  wc.on("did-navigate-in-page", push);
233
265
  wc.on("page-title-updated", push);
234
266
 
235
- const rec = { view, url: "", profile: profileIdx, visible: true };
267
+ rec.view = view;
236
268
  this.views.set(String(cellId), rec);
237
269
  return rec;
238
270
  }
@@ -281,7 +313,7 @@ class PanelCells {
281
313
  if (!wc || wc.isDestroyed()) continue;
282
314
  let url = "", title = "", loading = false;
283
315
  try { url = wc.getURL(); title = wc.getTitle(); loading = wc.isLoading(); } catch (e) {}
284
- out.push({ id, url, title, loading, wcId: wc.id, failed: /^chrome-error:/.test(url), error: wc.cicyLastError || null });
316
+ out.push({ id, url, title, loading, wcId: wc.id, failed: /^chrome-error:/.test(url), error: wc.cicyLastError || null, identity: rec.identity || null });
285
317
  }
286
318
  return out;
287
319
  }
@@ -359,6 +391,9 @@ function installIpc(findTab) {
359
391
  accountIdx: Number(p.accountIdx),
360
392
  name: String(p.name || ""),
361
393
  proxy: p.proxy && p.proxy.enabled ? String(p.proxy.url || "") : "",
394
+ note: String(p.note || ""),
395
+ telegram: telegramIdentity.telegramIdentityFromProfile(p),
396
+ ipInfo: p.ipInfo && p.ipInfo.ip ? { ip: String(p.ipInfo.ip), area: String(p.ipInfo.area || ""), probedAt: String(p.ipInfo.probedAt || "") } : null,
362
397
  }));
363
398
  } catch (err) { return []; }
364
399
  });
@@ -376,6 +411,50 @@ function installIpc(findTab) {
376
411
  await applyProfileProxy(profile.accountIdx, profile.proxy);
377
412
  return { accountIdx: profile.accountIdx, name: profile.name, proxy: profile.proxy.url };
378
413
  });
414
+ // 出口 IP / 地区:经该 profile 的 session(=它的代理)去问 IP 服务,结果写回 profile.ipInfo。
415
+ ipcMain.handle("panelcells:probe-ip", async (e, { accountIdx }) => {
416
+ if (!ctx(e)) throw new Error("Invalid panel");
417
+ const id = Number(accountIdx);
418
+ if (!Number.isInteger(id) || id <= 0) throw new Error("Invalid Electron profile ID");
419
+ const profileStore = require("../profiles/profile-store");
420
+ const { probeIpViaSession } = require("../utils/ip-probe");
421
+ const part = partitionFor(id);
422
+ // 没打开过的 profile 其 session 还没配代理,会探成本机 IP —— 先按存储的代理配好。
423
+ if (!appliedProxy.has(part)) {
424
+ const prof = profileStore.getProfile("electron", id);
425
+ const rules = profileStore.proxyRules(prof && prof.proxy);
426
+ await session.fromPartition(part).setProxy({ proxyRules: rules || "direct://", proxyBypassRules: "127.0.0.1,localhost,[::1]" });
427
+ appliedProxy.add(part);
428
+ }
429
+ const info = await probeIpViaSession(session.fromPartition(part));
430
+ if (!info.ip) return { accountIdx: id, ipInfo: null, error: "探测失败:代理不通或 IP 服务不可达" };
431
+ const view = profileStore.setIpInfo("electron", id, info);
432
+ return { accountIdx: id, ipInfo: view.ipInfo };
433
+ });
434
+ // 删除 profile:关掉所有面板里该 profile 的视图,清空其 session 存储(登录态、cookie),
435
+ // 再删记录。页面侧已二次确认。
436
+ ipcMain.handle("panelcells:remove-profile", async (e, { accountIdx }) => {
437
+ if (!ctx(e)) throw new Error("Invalid panel");
438
+ const id = Number(accountIdx);
439
+ if (!Number.isInteger(id) || id <= 0) throw new Error("Invalid Electron profile ID");
440
+ for (const pc of registry.values()) {
441
+ for (const [key, rec] of [...pc.views]) { if (rec.profile === id) pc.destroyCell(key); }
442
+ }
443
+ const part = partitionFor(id);
444
+ try { await session.fromPartition(part).clearStorageData(); } catch (err) {}
445
+ try { await session.fromPartition(part).clearCache(); } catch (err) {}
446
+ appliedProxy.delete(part);
447
+ const removed = require("../profiles/profile-store").removeProfile("electron", id);
448
+ return { accountIdx: id, removed };
449
+ });
450
+ ipcMain.handle("panelcells:set-profile-note", async (e, { accountIdx, note }) => {
451
+ if (!ctx(e)) throw new Error("Invalid panel");
452
+ const id = Number(accountIdx);
453
+ if (!Number.isInteger(id) || id <= 0) throw new Error("Invalid Electron profile ID");
454
+ const text = String(note || "").trim().slice(0, 500);
455
+ const profile = require("../profiles/profile-store").setNote("electron", id, text);
456
+ return { accountIdx: id, note: String(profile.note || "") };
457
+ });
379
458
  ipcMain.handle("panelcells:snapshots", async (e) => {
380
459
  const pc = ctx(e);
381
460
  return pc ? pc.snapshots() : [];
@@ -15,6 +15,9 @@ contextBridge.exposeInMainWorld("panelAPI", {
15
15
  profiles: () => ipcRenderer.invoke("panelcells:profiles"),
16
16
  addProfile: () => ipcRenderer.invoke("panelcells:add-profile"),
17
17
  setProfileProxy: (accountIdx, proxy) => ipcRenderer.invoke("panelcells:set-profile-proxy", { accountIdx, proxy }),
18
+ setProfileNote: (accountIdx, note) => ipcRenderer.invoke("panelcells:set-profile-note", { accountIdx, note }),
19
+ probeIp: (accountIdx) => ipcRenderer.invoke("panelcells:probe-ip", { accountIdx }),
20
+ removeProfile: (accountIdx) => ipcRenderer.invoke("panelcells:remove-profile", { accountIdx }),
18
21
  snapshots: () => ipcRenderer.invoke("panelcells:snapshots"),
19
22
  // divider drag: BrowserViews sit ABOVE the page and would swallow pointermove —
20
23
  // detach them for the duration of the drag (frame-only preview), reattach on up.
@@ -0,0 +1,77 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // telegram-identity.js — read "who is logged in" out of a Telegram Web (K)
5
+ // session so the matrix list can show each profile's @username.
6
+ //
7
+ // Telegram Web K keeps the current account id in localStorage `user_auth`
8
+ // ({id, dcID, date}) and caches user records in IndexedDB `tweb-account-<n>`
9
+ // (store `users`, keyed by the stringified user id). The self record carries
10
+ // username / first_name / last_name / phone. Nothing here touches auth keys.
11
+
12
+ const TELEGRAM_HOST_RE = /(^|\.)web\.telegram\.org$/i;
13
+
14
+ function isTelegramUrl(url) {
15
+ try { return TELEGRAM_HOST_RE.test(new URL(String(url || "")).hostname); } catch (e) { return false; }
16
+ }
17
+
18
+ // Runs inside the (sandboxed) Telegram page via executeJavaScript. Returns a
19
+ // plain object or null; never throws (the caller treats any failure as "unknown").
20
+ const TELEGRAM_IDENTITY_SCRIPT = `(async () => {
21
+ try {
22
+ const auth = JSON.parse(localStorage.getItem("user_auth") || "null");
23
+ const id = auth && (auth.id || auth.user_id);
24
+ if (!id) return null;
25
+ const key = String(id);
26
+ const names = (indexedDB.databases ? (await indexedDB.databases()).map((d) => d.name) : [])
27
+ .filter((n) => /^tweb-account-\\d+$/.test(String(n || "")));
28
+ if (!names.length) names.push("tweb-account-1");
29
+ for (const name of names) {
30
+ let db = null;
31
+ try {
32
+ db = await new Promise((res, rej) => { const r = indexedDB.open(name); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); r.onblocked = () => rej(new Error("blocked")); });
33
+ if (!db.objectStoreNames.contains("users")) { db.close(); continue; }
34
+ const user = await new Promise((res) => { const tx = db.transaction("users", "readonly"); const r = tx.objectStore("users").get(key); r.onsuccess = () => res(r.result || null); r.onerror = () => res(null); });
35
+ db.close();
36
+ if (user && String(user.id) === key) {
37
+ return { id: key, username: user.username || "", firstName: user.first_name || "", lastName: user.last_name || "", phone: user.phone || "" };
38
+ }
39
+ } catch (e) { try { db && db.close(); } catch (_) {} }
40
+ }
41
+ return { id: key, username: "", firstName: "", lastName: "", phone: "" };
42
+ } catch (e) { return null; }
43
+ })()`;
44
+
45
+ function normalizeTelegramIdentity(raw) {
46
+ if (!raw || typeof raw !== "object") return null;
47
+ const s = (v) => (typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "");
48
+ const id = s(raw.id);
49
+ if (!id) return null;
50
+ const username = s(raw.username).replace(/^@/, "");
51
+ // accepts the raw page shape (firstName/lastName) or an already-normalized one (displayName)
52
+ const displayName = s(raw.displayName) || [s(raw.firstName), s(raw.lastName)].filter(Boolean).join(" ");
53
+ return { id, username, displayName, phone: s(raw.phone) };
54
+ }
55
+
56
+ // Profile-store login record for this identity (keyed by name "telegram").
57
+ function telegramLoginRecord(identity) {
58
+ const it = normalizeTelegramIdentity(identity);
59
+ if (!it) return null;
60
+ return {
61
+ url: "https://web.telegram.org",
62
+ name: "telegram",
63
+ username: it.username,
64
+ mobile: it.phone,
65
+ note: it.displayName,
66
+ };
67
+ }
68
+
69
+ // Reverse: identity view from a stored profile (for list rendering).
70
+ function telegramIdentityFromProfile(profile) {
71
+ const logins = profile && Array.isArray(profile.logins) ? profile.logins : [];
72
+ const l = logins.find((x) => String((x && x.name) || "").toLowerCase() === "telegram");
73
+ if (!l) return null;
74
+ return { username: String(l.username || ""), displayName: String(l.note || ""), phone: String(l.mobile || "") };
75
+ }
76
+
77
+ module.exports = { isTelegramUrl, TELEGRAM_IDENTITY_SCRIPT, normalizeTelegramIdentity, telegramLoginRecord, telegramIdentityFromProfile };