cicy-desktop 2.1.280 → 2.1.281

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>
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
@@ -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,
@@ -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 };
@@ -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);
@@ -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,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
+ });