cicy-desktop 2.1.340 → 2.1.342

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.
@@ -15,7 +15,7 @@
15
15
  thing that breaks. -->
16
16
  <script>
17
17
  (function () {
18
- var STAMP = "20260903085137"; // filled in by the Worker on the way out
18
+ var STAMP = "20260903133941"; // filled in by the Worker on the way out
19
19
  window.__CICY_BUILD__ = STAMP; // the bundle's socket reuses this, one source of truth
20
20
  // The socket learns the deployed build on every connect, so this is only
21
21
  // the floor for a bundle too broken to hold a socket open. Keep it rare.
@@ -37,7 +37,7 @@
37
37
  setTimeout(check, 5000);
38
38
  })();
39
39
  </script>
40
- <script type="module" crossorigin src="./assets/index-B72KF7tw.js"></script>
40
+ <script type="module" crossorigin src="./assets/index-OG6JB73F.js"></script>
41
41
  <link rel="stylesheet" crossorigin href="./assets/index-C6Zrdgc0.css">
42
42
  </head>
43
43
  <body>
@@ -369,6 +369,15 @@ function register({ sidecarLogPath } = {}) {
369
369
  // 已在跑也同步一次配置(容器侧节点可能被云端更新),变了才重启。幂等。二进制不在会自动下。
370
370
  async function maybeStartChromeProxy() {
371
371
  if (!APP_DOCKER_SUPPORTED) return;
372
+ // 「独立管理」开关:标记文件在 → 不从容器同步覆盖宿主配置,host mihomo
373
+ // 用宿主自己的 mihomo-host.yaml 独立跑(没跑就用现有宿主配置起来)。幂等。
374
+ if (hostMihomo.standalonePinned()) {
375
+ if (!hostMihomo.running() && hostMihomo.binPresent()) {
376
+ try { await hostMihomo.enable({ containerYaml: null }); }
377
+ catch (e) { log.warn(`[chrome-proxy] standalone start failed: ${e.message}`); }
378
+ }
379
+ return;
380
+ }
372
381
  const [yaml, selections] = await Promise.all([
373
382
  appDocker.readMihomoConfig(APP_CONTAINER),
374
383
  appDocker.readMihomoSelections(APP_CONTAINER),
@@ -60,6 +60,11 @@ function ensureLink() {
60
60
  } catch {}
61
61
  }
62
62
  const HOST_CONFIG = path.join(os.homedir(), "cicy-ai", "db", "mihomo-host.yaml");
63
+ // 「独立管理」开关:此标记文件存在时,不再从容器同步覆盖 mihomo-host.yaml,
64
+ // host mihomo 用宿主自己的配置独立运行(见 sidecar-ipc maybeStartChromeProxy)。
65
+ // 按机器可选,默认不存在=保持原有容器→宿主同步行为。删掉文件即恢复同步。
66
+ const STANDALONE_FLAG = path.join(os.homedir(), "cicy-ai", "db", "mihomo-host.standalone");
67
+ function standalonePinned() { try { return fs.existsSync(STANDALONE_FLAG); } catch { return false; } }
63
68
  // Log lives under ~/logs, NOT db/ — db/ holds data + config only (config below
64
69
  // stays in db/ as mihomo-host.yaml; this is just the runtime log).
65
70
  const HOST_LOG = path.join(os.homedir(), "logs", "mihomo-host.log");
@@ -255,6 +260,6 @@ async function enable({ containerYaml, selections = {}, emit } = {}) {
255
260
  module.exports = {
256
261
  VER, assetUrl, binPath, binPresent, ensureBinary,
257
262
  buildHostConfig, writeConfig, planSelectionUpdates, syncSelections,
258
- start, stop, running, enable,
259
- HOST_CONFIG, HOST_LOG,
263
+ start, stop, running, enable, standalonePinned,
264
+ HOST_CONFIG, HOST_LOG, STANDALONE_FLAG,
260
265
  };
@@ -48,6 +48,17 @@ function profileForCell(url, requested) {
48
48
  }
49
49
  const telegramIdentity = require("./telegram-identity");
50
50
  const facebookIdentity = require("./facebook-identity");
51
+ const tiktokIdentity = require("./tiktok-identity");
52
+
53
+ // 站点 → 身份模块。加平台只加一行,不再叠三元表达式。
54
+ const IDENTITY_SITES = [
55
+ { match: (u) => telegramIdentity.isTelegramUrl(u), script: () => telegramIdentity.TELEGRAM_IDENTITY_SCRIPT,
56
+ normalize: (r) => telegramIdentity.normalizeTelegramIdentity(r), record: (it) => telegramIdentity.telegramLoginRecord(it) },
57
+ { match: (u) => facebookIdentity.isFacebookUrl(u), script: () => facebookIdentity.FACEBOOK_IDENTITY_SCRIPT,
58
+ normalize: (r) => facebookIdentity.normalizeFacebookIdentity(r), record: (it) => facebookIdentity.facebookLoginRecord(it) },
59
+ { match: (u) => tiktokIdentity.isTiktokUrl(u), script: () => tiktokIdentity.TIKTOK_IDENTITY_SCRIPT,
60
+ normalize: (r) => tiktokIdentity.normalizeTiktokIdentity(r), record: (it) => tiktokIdentity.tiktokLoginRecord(it) },
61
+ ];
51
62
  const appliedProxy = new Set(); // partitions whose proxy is already configured
52
63
  function ensureCellSessionProxy(idx) {
53
64
  const part = partitionFor(idx);
@@ -229,12 +240,12 @@ class PanelCells {
229
240
  const detectIdentity = () => {
230
241
  if (profileIdx === 0) return;
231
242
  let url = ""; try { url = wc.getURL(); } catch (e) {}
232
- // 站点 → 身份模块(Telegram Web K / Facebook);其他站点不探。
233
- const site = telegramIdentity.isTelegramUrl(url) ? telegramIdentity : facebookIdentity.isFacebookUrl(url) ? facebookIdentity : null;
243
+ // 站点 → 身份模块(Telegram / Facebook / TikTok);其他站点不探。
244
+ const site = IDENTITY_SITES.find((s) => s.match(url)) || null;
234
245
  if (!site) return;
235
- const script = site === telegramIdentity ? telegramIdentity.TELEGRAM_IDENTITY_SCRIPT : facebookIdentity.FACEBOOK_IDENTITY_SCRIPT;
236
- const normalize = site === telegramIdentity ? telegramIdentity.normalizeTelegramIdentity : facebookIdentity.normalizeFacebookIdentity;
237
- const record = site === telegramIdentity ? telegramIdentity.telegramLoginRecord : facebookIdentity.facebookLoginRecord;
246
+ const script = site.script();
247
+ const normalize = site.normalize;
248
+ const record = site.record;
238
249
  const seq = ++identSeq;
239
250
  const delays = [2500, 8000, 20000, 45000];
240
251
  const tick = async (i) => {
@@ -401,6 +412,7 @@ function installIpc(findTab) {
401
412
  login: { phone: String((p.telegramLogin || {}).phone || ""), codeUrl: String((p.telegramLogin || {}).codeUrl || "") },
402
413
  telegram: telegramIdentity.telegramIdentityFromProfile(p),
403
414
  facebook: facebookIdentity.facebookIdentityFromProfile(p),
415
+ tiktok: tiktokIdentity.tiktokIdentityFromProfile(p),
404
416
  ipInfo: p.ipInfo && p.ipInfo.ip ? { ip: String(p.ipInfo.ip), area: String(p.ipInfo.area || ""), probedAt: String(p.ipInfo.probedAt || "") } : null,
405
417
  }));
406
418
  } catch (err) { return []; }
@@ -24,6 +24,7 @@ const BUILTIN = [
24
24
  { id: "telegram-matrix", title: "Telegram 矩阵" },
25
25
  { id: "redroid-matrix", title: "Redroid 矩阵" },
26
26
  { id: "facebook-matrix", title: "Facebook 矩阵" },
27
+ { id: "tiktok-matrix", title: "TikTok 矩阵" },
27
28
  ];
28
29
  const BUILTIN_IDS = new Set(BUILTIN.map((b) => b.id));
29
30
 
@@ -4,6 +4,7 @@ const PAGES = {
4
4
  "telegram-matrix": "telegram-matrix.html",
5
5
  "redroid-matrix": "redroid-matrix.html",
6
6
  "facebook-matrix": "facebook-matrix.html",
7
+ "tiktok-matrix": "tiktok-matrix.html",
7
8
  };
8
9
 
9
10
  function panelPageForUrl(value) {
@@ -23,6 +23,11 @@ const PRESETS = {
23
23
  title: "Facebook 矩阵",
24
24
  query: "preset=facebook-matrix",
25
25
  },
26
+ "tiktok-matrix": {
27
+ preset: "tiktok-matrix",
28
+ title: "TikTok 矩阵",
29
+ query: "preset=tiktok-matrix",
30
+ },
26
31
  };
27
32
 
28
33
  function resolvePanelPreset(value) {
@@ -0,0 +1,102 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // tiktok-identity.js — read "who is logged in" out of a TikTok web session so
5
+ // the TikTok 矩阵 list can show each profile's account name.
6
+ //
7
+ // TikTok ships the logged-in user in a JSON blob the page bootstraps from:
8
+ // `__UNIVERSAL_DATA_FOR_REHYDRATION__` (current builds) or the older
9
+ // `SIGI_STATE`, both under webapp.user-detail / AppContext → user.uniqueId /
10
+ // nickname / secUid. The `sessionid` cookie only tells us *that* someone is
11
+ // logged in, never who, so the payload is the only reliable source.
12
+ //
13
+ // We read uniqueId (@handle), nickname (display name) and the numeric id.
14
+ // Nothing here reads cookie *values* — a session cookie is never exfiltrated.
15
+
16
+ const TIKTOK_HOST_RE = /(^|\.)tiktok\.com$/i;
17
+
18
+ function isTiktokUrl(url) {
19
+ try { return TIKTOK_HOST_RE.test(new URL(String(url || "")).hostname); } catch (e) { return false; }
20
+ }
21
+
22
+ const TIKTOK_IDENTITY_SCRIPT = `(() => {
23
+ try {
24
+ const readJson = (id) => {
25
+ const el = document.getElementById(id);
26
+ if (!el || !el.textContent) return null;
27
+ try { return JSON.parse(el.textContent); } catch (e) { return null; }
28
+ };
29
+ // 新版:__UNIVERSAL_DATA_FOR_REHYDRATION__ 里 webapp.app-context 带当前登录用户
30
+ const uni = readJson("__UNIVERSAL_DATA_FOR_REHYDRATION__");
31
+ const scopes = (uni && uni.__DEFAULT_SCOPE__) || {};
32
+ const ctx = scopes["webapp.app-context"] || {};
33
+ let uniqueId = ctx.userId ? "" : "";
34
+ let nickname = "", id = "", secUid = "";
35
+ if (ctx && (ctx.uniqueId || ctx.userId)) {
36
+ uniqueId = ctx.uniqueId || "";
37
+ id = String(ctx.userId || "");
38
+ nickname = ctx.nickName || ctx.nickname || "";
39
+ secUid = ctx.secUserId || "";
40
+ }
41
+ // 旧版:SIGI_STATE.AppContext / UserModule
42
+ if (!uniqueId && !id) {
43
+ const sigi = readJson("SIGI_STATE") || (window.SIGI_STATE || null);
44
+ const app = (sigi && (sigi.AppContext || sigi.appContext)) || {};
45
+ uniqueId = app.uniqueId || uniqueId;
46
+ id = String(app.userId || id || "");
47
+ nickname = app.nickName || app.nickname || nickname;
48
+ secUid = app.secUserId || secUid;
49
+ }
50
+ // 兜底:整页 HTML 里捞一次(结构变了也还能读到)
51
+ if (!uniqueId && !id) {
52
+ const html = document.documentElement ? document.documentElement.innerHTML : "";
53
+ uniqueId = (html.match(/"uniqueId":"((?:[^"\\\\]|\\\\.)*)"/) || [])[1] || "";
54
+ nickname = (html.match(/"nickname":"((?:[^"\\\\]|\\\\.)*)"/) || [])[1] || nickname;
55
+ id = (html.match(/"userId":"(\\d+)"/) || [])[1] || id;
56
+ }
57
+ const dec = (s) => { try { return JSON.parse('"' + s + '"'); } catch (e) { return s; } };
58
+ if (!uniqueId && !id) return null;
59
+ return { id: String(id || ""), username: dec(uniqueId || ""), displayName: dec(nickname || ""), secUid: String(secUid || "") };
60
+ } catch (e) { return null; }
61
+ })()`;
62
+
63
+ function normalizeTiktokIdentity(raw) {
64
+ if (!raw || typeof raw !== "object") return null;
65
+ const s = (v) => (typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "");
66
+ const username = s(raw.username).replace(/^@/, "");
67
+ const id = s(raw.id);
68
+ // TikTok 的 @handle 才是人认得出的身份;没有 handle 只有数字 id 也算数,但两者都空就不成立
69
+ if (!username && (!id || id === "0")) return null;
70
+ return { id: id && id !== "0" ? id : "", username, displayName: s(raw.displayName), phone: "" };
71
+ }
72
+
73
+ // Profile-store login record (keyed by name "tiktok").
74
+ // username 存 @handle —— 和 telegram 存 handle、facebook 存数字 id 保持一致的语义:
75
+ // 这一列永远是「人能认出来的账号标识」。
76
+ function tiktokLoginRecord(identity) {
77
+ const it = normalizeTiktokIdentity(identity);
78
+ if (!it) return null;
79
+ return {
80
+ url: "https://www.tiktok.com",
81
+ name: "tiktok",
82
+ username: it.username || it.id,
83
+ mobile: "",
84
+ note: it.displayName,
85
+ };
86
+ }
87
+
88
+ // Reverse: identity view from a stored profile (for list rendering).
89
+ function tiktokIdentityFromProfile(profile) {
90
+ const logins = profile && Array.isArray(profile.logins) ? profile.logins : [];
91
+ const l = logins.find((x) => String((x && x.name) || "").toLowerCase() === "tiktok");
92
+ if (!l) return null;
93
+ return { id: "", username: String(l.username || ""), displayName: String(l.note || ""), phone: "" };
94
+ }
95
+
96
+ module.exports = {
97
+ isTiktokUrl,
98
+ TIKTOK_IDENTITY_SCRIPT,
99
+ normalizeTiktokIdentity,
100
+ tiktokLoginRecord,
101
+ tiktokIdentityFromProfile,
102
+ };
@@ -0,0 +1,381 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>TikTok 矩阵</title>
7
+ <style>
8
+ :root { color-scheme: dark; --bg:#0A0A0A; --cell:#141416; --head:#1a1a1c; --line:rgba(255,255,255,.08); --fg:#e6e6e9; --muted:#8b8b92; --accent:#3b82f6; --hover:rgba(255,255,255,.06); font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
9
+ * { box-sizing: border-box; }
10
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: var(--bg); color: var(--fg); }
11
+ body { display: grid; grid-template-columns: minmax(320px, 34%) 1fr; }
12
+ aside { min-width: 0; border-right: 1px solid var(--line); background: var(--bg); display: flex; flex-direction: column; }
13
+ header { flex: none; height: 56px; padding: 0 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
14
+ h1 { margin: 0; font-size: 15px; display: flex; align-items: baseline; gap: 8px; }
15
+ h1 small { color: var(--muted); font-weight: 500; font-size: 12px; }
16
+ button { border: 0; border-radius: 8px; padding: 7px 12px; color: white; background: var(--accent); cursor: pointer; font-weight: 600; font-size: 13px; white-space: nowrap; line-height: 1.2; }
17
+ button:hover { filter: brightness(1.08); }
18
+ button:disabled { opacity: .55; cursor: wait; }
19
+ button.ghost { background: var(--head); color: var(--fg); border: 1px solid var(--line); }
20
+ button.sm { padding: 4px 8px; font-size: 12px; }
21
+ .icon { background: transparent; color: #b4b4bb; padding: 3px 6px; font-size: 13px; border-radius: 6px; }
22
+ .icon:hover { background: var(--hover); color: #fff; }
23
+ .list { flex: 1; overflow: auto; padding: 8px; }
24
+ #rows { display: flex; flex-direction: column; gap: 4px; }
25
+ .row { padding: 9px 10px; border-radius: 10px; cursor: pointer; border: 1px solid transparent; min-width: 0; }
26
+ .row:hover { background: var(--hover); }
27
+ .row.active { background: var(--cell); border-color: rgba(59,130,246,.55); }
28
+ .row.failed .name { color: #ff9e9e; }
29
+ .line1 { display: flex; align-items: center; gap: 8px; min-width: 0; }
30
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: #3a3a40; flex: none; }
31
+ .dot.loading { background: #f0b429; animation: pulse 1s infinite alternate; }
32
+ .dot.ok { background: #3fb950; }
33
+ .dot.failed { background: #ff5c5c; }
34
+ @keyframes pulse { from { opacity: .35; } to { opacity: 1; } }
35
+ .name { font-weight: 650; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
36
+ .chip { flex: none; padding: 1px 6px; border-radius: 999px; background: var(--head); color: #b4b4bb; font: 11px ui-monospace, monospace; }
37
+ .st { flex: none; color: var(--muted); font-size: 11px; white-space: nowrap; margin-left: auto; }
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
+ /* 编辑态就地变成可输入,尺寸/位置与只读态完全一致,不发生位移 */
43
+ .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; }
44
+ .line2 .ip.none { color: #6b6b72; font-family: inherit; }
45
+ .line2 .ip.busy { color: #f0b429; }
46
+ .line2 .ip.bad { color: #ff8a8a; }
47
+ .line2 .ip:hover { color: #fff; }
48
+ .cfg { display: none; margin: 8px 0 2px 16px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--head); }
49
+ .row.cfg-open .cfg { display: block; }
50
+ .row.cfg-open .gear { color: var(--accent); }
51
+ .cfg .f { display: flex; align-items: center; gap: 6px; }
52
+ .cfg .f + .f { margin-top: 6px; }
53
+ .cfg label { flex: none; width: 34px; font-size: 11px; color: var(--muted); }
54
+ .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; }
55
+ .cfg input:focus { border-color: var(--accent); }
56
+ .cfg .hint { font-size: 11px; color: var(--muted); padding-left: 40px; }
57
+ .cfg .hint b { color: #b4b4bb; font-weight: 500; }
58
+ .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); }
59
+ .cfg .danger .sp { flex: 1; }
60
+ .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; }
61
+ .cfg .confirm.on { display: flex; }
62
+ .cfg .confirm .sp { flex: 1; }
63
+ button.del { background: #b3261e; }
64
+ .row-err { padding: 4px 0 0 16px; color: #ff8a8a; font-size: 11px; display: none; }
65
+ .row-err.on { display: block; }
66
+ #empty { display: none; padding: 40px 24px; color: var(--muted); text-align: center; line-height: 1.8; }
67
+ #empty.on { display: block; }
68
+ .hint { flex: none; padding: 8px 16px; color: var(--muted); font-size: 11px; border-top: 1px solid var(--line); }
69
+ main { min-width: 0; display: flex; flex-direction: column; align-items: center; padding: 14px 18px; background: var(--bg); }
70
+ #preview-title { width: min(410px, calc(100% - 20px)); margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #b4b4bb; font-size: 12px; }
71
+ #preview-title .left { display: flex; align-items: center; gap: 8px; min-width: 0; }
72
+ #selected-name { color: var(--fg); font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
73
+ #status { color: var(--muted); white-space: nowrap; }
74
+ .actions { display: flex; gap: 6px; flex: none; }
75
+ .actions button { padding: 5px 9px; font-size: 12px; }
76
+ #phone { position: relative; width: min(410px, calc(100% - 20px)); height: min(760px, calc(100vh - 92px)); min-height: 420px; padding: 10px; overflow: hidden; border: 1px solid var(--line); border-radius: 28px; background: var(--cell); box-shadow: 0 24px 70px rgba(0,0,0,.45); }
77
+ #phone-preview { width: 100%; height: 100%; overflow: hidden; border-radius: 19px; background: #fff; }
78
+ .overlay { position: absolute; inset: 10px; border-radius: 19px; display: none; place-items: center; text-align: center; padding: 28px; color: var(--fg); background: var(--head); z-index: 2; }
79
+ .overlay.on { display: grid; }
80
+ .overlay h2 { margin: 0 0 8px; font-size: 16px; color: #ff8a8a; }
81
+ .overlay p { margin: 0 0 14px; font-size: 13px; line-height: 1.6; color: var(--muted); word-break: break-all; }
82
+ .overlay code { color: var(--fg); font: 12px ui-monospace, monospace; }
83
+ .overlay .btns { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
84
+ .spinner { width: 28px; height: 28px; border: 3px solid rgba(255,255,255,.12); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin: 0 auto 12px; }
85
+ @keyframes spin { to { transform: rotate(360deg); } }
86
+ </style>
87
+ </head>
88
+ <body>
89
+ <aside>
90
+ <header>
91
+ <h1>TikTok 矩阵 <small id="count"></small></h1>
92
+ <button id="add-profile" title="新建一个独立会话(persist:sandbox-N)">+ 添加 Profile</button>
93
+ </header>
94
+ <div class="list">
95
+ <div id="rows"></div>
96
+ <div id="empty">还没有 Profile<br>点击右上角「添加 Profile」创建第一个 TikTok 会话</div>
97
+ </div>
98
+ <div class="hint">点击行切换预览;点代理地址可修改(留空 = 直连)。已打开过的会话会在后台保持登录。</div>
99
+ </aside>
100
+ <main>
101
+ <div id="preview-title">
102
+ <div class="left"><span class="dot" id="sel-dot"></span><span id="selected-name">未选择 Profile</span><span id="status"></span></div>
103
+ <div class="actions"><button class="ghost" id="reload" title="重新加载 TikTok">重新加载</button></div>
104
+ </div>
105
+ <div id="phone">
106
+ <div id="phone-preview"></div>
107
+ <div class="overlay on" id="ov-empty"><div>选择左侧一个 Profile 预览 TikTok</div></div>
108
+ <div class="overlay" id="ov-loading"><div><div class="spinner"></div><div>正在加载 TikTok…</div></div></div>
109
+ <div class="overlay" id="ov-error">
110
+ <div>
111
+ <h2>无法连接 TikTok</h2>
112
+ <p id="err-detail"></p>
113
+ <div class="btns"><button id="err-retry">重试</button><button class="ghost" id="err-cfg">代理设置</button></div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </main>
118
+ <script>
119
+ const SITE_URL = 'https://www.tiktok.com/';
120
+ const rowsEl = document.getElementById('rows');
121
+ const empty = document.getElementById('empty');
122
+ const countEl = document.getElementById('count');
123
+ const addButton = document.getElementById('add-profile');
124
+ const preview = document.getElementById('phone-preview');
125
+ const selectedName = document.getElementById('selected-name');
126
+ const selDot = document.getElementById('sel-dot');
127
+ const statusEl = document.getElementById('status');
128
+ const ov = { empty: document.getElementById('ov-empty'), loading: document.getElementById('ov-loading'), error: document.getElementById('ov-error') };
129
+ const errDetail = document.getElementById('err-detail');
130
+ let profiles = [];
131
+ let selected = null;
132
+ const openedProfiles = new Set(); // 打开过的会话保留在后台(登录态不丢),只有选中的贴到预览区
133
+ const cellState = new Map(); // accountIdx -> { loading, failed, error, url, title }
134
+ const rows = new Map(); // accountIdx -> row element
135
+
136
+ const esc = (value) => String(value || '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
137
+ const cellId = (idx) => `tiktok-preview-${idx}`;
138
+ const stateOf = (idx) => cellState.get(Number(idx)) || {};
139
+ const dotClass = (st) => st.loading ? 'loading' : st.failed ? 'failed' : (st.url ? 'ok' : '');
140
+ const stateText = (st, opened) => st.loading ? '加载中…' : st.failed ? '连接失败' : st.url ? '已加载' : (opened ? '' : '未打开');
141
+ 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)} 天前`; };
142
+ function renderIp(p, mode) {
143
+ const row = rows.get(p.accountIdx); if (!row) return;
144
+ const el = row.querySelector('.ip');
145
+ el.className = 'ip';
146
+ if (mode === 'busy') { el.classList.add('busy'); el.textContent = '测 IP…'; el.title = ''; return; }
147
+ if (mode && mode.error) { el.classList.add('bad'); el.textContent = 'IP 探测失败'; el.title = mode.error + '(点击重试)'; return; }
148
+ const info = p.ipInfo;
149
+ if (info && info.ip) {
150
+ el.textContent = info.area ? `${info.area} · ${info.ip}` : info.ip;
151
+ el.title = `出口 IP ${info.ip}${info.area ? ',' + info.area : ''},${fmtAgo(info.probedAt) || '未知时间'}探测(点击重测)`;
152
+ } else { el.classList.add('none'); el.textContent = '测 IP'; el.title = '经该 Profile 的代理探测出口 IP / 地区'; }
153
+ }
154
+ async function probeIp(p) {
155
+ renderIp(p, 'busy');
156
+ try {
157
+ const r = await window.panelAPI.probeIp(p.accountIdx);
158
+ if (r && r.ipInfo) { p.ipInfo = r.ipInfo; renderIp(p); }
159
+ else renderIp(p, { error: (r && r.error) || '探测失败' });
160
+ } catch (e) { renderIp(p, { error: e.message || String(e) }); }
161
+ }
162
+ // 身份:优先用本次会话探到的(cellState.identity),否则用 profile 里存的(上次登录)。
163
+ const identityOf = (p) => stateOf(p.accountIdx).identity || p.tiktok || null;
164
+ function renderIdentity(p) {
165
+ const row = rows.get(p.accountIdx); if (!row) return;
166
+ const el = row.querySelector('.tg');
167
+ const it = identityOf(p);
168
+ if (it && (it.username || it.displayName || it.phone)) {
169
+ el.classList.remove('none');
170
+ 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)}` : '');
171
+ el.title = [it.username && `@${it.username}`, it.displayName, it.phone && `+${it.phone}`].filter(Boolean).join(' ');
172
+ } else {
173
+ el.classList.add('none');
174
+ el.textContent = openedProfiles.has(p.accountIdx) && stateOf(p.accountIdx).url ? '未登录' : '—';
175
+ el.title = '';
176
+ }
177
+ }
178
+
179
+ function flash(text, ms = 2500) {
180
+ statusEl.textContent = text; statusEl.dataset.sticky = '1';
181
+ clearTimeout(flash.t);
182
+ flash.t = setTimeout(() => { delete statusEl.dataset.sticky; renderPreviewState(); }, ms);
183
+ }
184
+
185
+ function syncPreview() {
186
+ if (!window.panelAPI) return;
187
+ const rect = preview.getBoundingClientRect();
188
+ const cells = [...openedProfiles].map((profileId) => ({
189
+ id: cellId(profileId),
190
+ url: SITE_URL,
191
+ profile: profileId,
192
+ visible: !!selected && profileId === selected.accountIdx,
193
+ rect: selected && profileId === selected.accountIdx ? { x: rect.x, y: rect.y, w: rect.width, h: rect.height } : null,
194
+ }));
195
+ try { window.panelAPI.sync(cells); } catch (e) {}
196
+ }
197
+
198
+ function describeError(p, st) {
199
+ const e = st.error || {};
200
+ const desc = e.description ? `<code>${esc(e.description)}</code>` : '';
201
+ if (p.proxy) return `代理 <code>${esc(p.proxy)}</code> 不可用或无法访问 TikTok。${desc}<br>点 ⚙ 检查或更换该 Profile 的代理,可用「测 IP」验证。`;
202
+ return `当前为直连,网络无法访问 TikTok。${desc}<br>点 ⚙ 为该 Profile 设置代理。`;
203
+ }
204
+
205
+ function renderPreviewState() {
206
+ const st = selected ? stateOf(selected.accountIdx) : {};
207
+ ov.empty.classList.toggle('on', !selected);
208
+ ov.loading.classList.toggle('on', !!selected && !!st.loading && !st.url);
209
+ ov.error.classList.toggle('on', !!selected && !st.loading && !!st.failed);
210
+ if (selected && st.failed) errDetail.innerHTML = describeError(selected, st);
211
+ selDot.className = `dot ${selected ? dotClass(st) : ''}`;
212
+ if (selected && !statusEl.dataset.sticky) statusEl.textContent = stateText(st, true);
213
+ }
214
+
215
+ function renderRowState(idx) {
216
+ const row = rows.get(idx); if (!row) return;
217
+ const st = stateOf(idx);
218
+ row.classList.toggle('failed', !!st.failed && !st.loading);
219
+ row.querySelector('.dot').className = `dot ${dotClass(st)}`;
220
+ row.querySelector('.st').textContent = stateText(st, openedProfiles.has(idx));
221
+ }
222
+
223
+ function buildRow(p) {
224
+ const row = document.createElement('div');
225
+ row.className = 'row'; row.dataset.id = String(p.accountIdx); row.title = `persist:sandbox-${p.accountIdx}`;
226
+ row.innerHTML = `
227
+ <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>
228
+ <div class="line2"><span class="tg none">—</span><span class="ip none" title="经该 Profile 的代理探测出口 IP / 地区">测 IP</span></div>
229
+ <div class="cfg">
230
+ <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>
231
+ <div class="hint">保存后已打开的会话会重新加载;<b>测 IP</b> 经该代理访问 IP 服务,结果显示在上方。</div>
232
+ <div class="danger"><span>删除此 Profile 会一并清除它的 TikTok 登录态和缓存。</span><span class="sp"></span><button class="sm ghost rm">删除</button></div>
233
+ <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>
234
+ </div>
235
+ <div class="row-err"></div>`;
236
+ const errEl = row.querySelector('.row-err');
237
+ // 备注功能已按需求移除(列表只保留 名称 / @用户名 / 状态 / IP)
238
+ // 代理设置抽屉(⚙):不常用,默认收起,列表保持干净。
239
+ const proxyInput = row.querySelector('[data-role="proxy"]');
240
+ const saveBtn = row.querySelector('.cfg .save');
241
+ 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(); } };
242
+ const saveProxy = async () => {
243
+ saveBtn.disabled = true; errEl.classList.remove('on');
244
+ try {
245
+ const updated = await window.panelAPI.setProfileProxy(p.accountIdx, proxyInput.value.trim());
246
+ p.proxy = updated.proxy; p.ipInfo = null; renderIp(p);
247
+ if (openedProfiles.has(p.accountIdx)) { reloadProfile(p.accountIdx); flash(`#${p.accountIdx} 代理已应用,重新加载中`); }
248
+ else flash(`#${p.accountIdx} 代理已保存,打开时生效`);
249
+ row.classList.remove('cfg-open');
250
+ } catch (e) { errEl.textContent = e.message || String(e); errEl.classList.add('on'); }
251
+ finally { saveBtn.disabled = false; }
252
+ };
253
+ row.querySelector('.gear').onclick = toggleCfg;
254
+ row.querySelector('.cfg').onclick = (e) => e.stopPropagation();
255
+ saveBtn.onclick = saveProxy;
256
+ proxyInput.onkeydown = (e) => { e.stopPropagation(); if (e.key === 'Enter') saveProxy(); if (e.key === 'Escape') row.classList.remove('cfg-open'); };
257
+ row.querySelector('.cfg .test').onclick = () => probeIp(p);
258
+ row.querySelector('.ip').onclick = (e) => { e.stopPropagation(); probeIp(p); };
259
+ const confirmEl = row.querySelector('.cfg .confirm');
260
+ row.querySelector('.rm').onclick = () => { confirmEl.classList.add('on'); row.querySelector('.rm-yes').focus(); };
261
+ row.querySelector('.rm-no').onclick = () => confirmEl.classList.remove('on');
262
+ row.querySelector('.rm-yes').onclick = async () => {
263
+ const btn = row.querySelector('.rm-yes'); btn.disabled = true;
264
+ try { await removeProfile(p); }
265
+ catch (e) { errEl.textContent = e.message || String(e); errEl.classList.add('on'); btn.disabled = false; }
266
+ };
267
+ row._openCfg = () => { if (!row.classList.contains('cfg-open')) toggleCfg(new Event('click')); row.scrollIntoView({ block: 'nearest' }); };
268
+ row.onclick = () => selectProfile(p.accountIdx);
269
+ row.querySelector('.reload').onclick = (e) => { e.stopPropagation(); reloadProfile(p.accountIdx); };
270
+ return row;
271
+ }
272
+
273
+ function reloadProfile(idx) {
274
+ const fresh = !openedProfiles.has(idx);
275
+ openedProfiles.add(idx);
276
+ cellState.set(idx, { loading: true }); renderRowState(idx);
277
+ if (selected && selected.accountIdx === idx) renderPreviewState();
278
+ syncPreview();
279
+ // 首次打开:上面的 sync() 已经会建视图并加载 URL,再 reload 只是白加载一次。
280
+ if (!fresh) { try { window.panelAPI.reload(cellId(idx)); } catch (e) {} }
281
+ }
282
+
283
+ function render() {
284
+ empty.classList.toggle('on', profiles.length === 0);
285
+ countEl.textContent = profiles.length ? `${profiles.length} 个会话` : '';
286
+ for (const p of profiles) {
287
+ if (!rows.has(p.accountIdx)) { const row = buildRow(p); rows.set(p.accountIdx, row); rowsEl.appendChild(row); }
288
+ rows.get(p.accountIdx).classList.toggle('active', !!selected && selected.accountIdx === p.accountIdx);
289
+ renderRowState(p.accountIdx);
290
+ renderIdentity(p);
291
+ renderIp(p);
292
+ }
293
+ }
294
+
295
+ function selectProfile(accountIdx) {
296
+ selected = profiles.find((p) => p.accountIdx === Number(accountIdx)) || null;
297
+ if (selected) { openedProfiles.add(selected.accountIdx); localStorage.setItem('tiktok-matrix-profile', String(selected.accountIdx)); }
298
+ selectedName.textContent = selected ? `${selected.name} · #${selected.accountIdx}` : '未选择 Profile';
299
+ selectedName.title = selected ? `persist:sandbox-${selected.accountIdx}` : '';
300
+ delete statusEl.dataset.sticky;
301
+ render();
302
+ renderPreviewState();
303
+ syncPreview();
304
+ if (selected && !cellState.has(selected.accountIdx)) setTimeout(pullStates, 300);
305
+ }
306
+
307
+ function applyCellState(state) {
308
+ const m = /^tiktok-preview-(\d+)$/.exec(state.id || '');
309
+ if (!m) return;
310
+ const idx = Number(m[1]);
311
+ const prev = stateOf(idx);
312
+ const failed = !!state.failed || /^chrome-error:/.test(state.url || '');
313
+ const next = { ...prev, loading: !!state.loading };
314
+ if (state.url !== undefined) { next.url = failed ? '' : state.url; next.title = state.title || ''; }
315
+ if (!state.loading) { next.failed = failed; next.error = state.error || (failed ? prev.error : null); }
316
+ if (state.identity) next.identity = state.identity;
317
+ cellState.set(idx, next);
318
+ renderRowState(idx);
319
+ { const p = profiles.find((x) => x.accountIdx === idx); if (p) renderIdentity(p); }
320
+ if (selected && selected.accountIdx === idx) renderPreviewState();
321
+ }
322
+ async function pullStates({ adopt = false } = {}) {
323
+ let list = [];
324
+ try { list = await window.panelAPI.states(); } catch (e) { return; }
325
+ for (const st of list || []) {
326
+ // 面板页刷新后 openedProfiles 是空的,而主进程里那些会话视图还活着。
327
+ // 不先认领回来,第一次 syncPreview() 就会把它们当多余格子销毁,后台
328
+ // 登录着的会话全没(实测:刷新后 #1 的 webContents 被销毁)。
329
+ if (adopt) {
330
+ const m = /^tiktok-preview-(\d+)$/.exec(st.id || '');
331
+ if (m) openedProfiles.add(Number(m[1]));
332
+ }
333
+ applyCellState(st);
334
+ }
335
+ }
336
+
337
+ async function loadProfiles(preferredId) {
338
+ profiles = await window.panelAPI.profiles();
339
+ profiles.sort((a, b) => a.accountIdx - b.accountIdx);
340
+ const remembered = Number(localStorage.getItem('tiktok-matrix-profile'));
341
+ const target = preferredId || (profiles.some((p) => p.accountIdx === remembered) ? remembered : 0) || (profiles[0] && profiles[0].accountIdx);
342
+ selectProfile(target);
343
+ }
344
+
345
+ async function removeProfile(p) {
346
+ const idx = p.accountIdx;
347
+ await window.panelAPI.removeProfile(idx);
348
+ openedProfiles.delete(idx); cellState.delete(idx);
349
+ const row = rows.get(idx); if (row) row.remove(); rows.delete(idx);
350
+ profiles = profiles.filter((x) => x.accountIdx !== idx);
351
+ if (selected && selected.accountIdx === idx) {
352
+ const next = profiles[0] ? profiles[0].accountIdx : 0;
353
+ if (next) selectProfile(next); else { selected = null; localStorage.removeItem('tiktok-matrix-profile'); selectedName.textContent = '未选择 Profile'; render(); renderPreviewState(); syncPreview(); }
354
+ } else render();
355
+ flash(`Profile #${idx} 已删除`);
356
+ }
357
+
358
+ addButton.onclick = async () => {
359
+ addButton.disabled = true; flash('正在创建…', 60000);
360
+ try {
361
+ const profile = await window.panelAPI.addProfile();
362
+ await loadProfiles(profile.accountIdx);
363
+ flash(`Profile #${profile.accountIdx} 已创建`);
364
+ const row = rows.get(profile.accountIdx); if (row) row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
365
+ } catch (e) { flash(e.message || String(e), 6000); }
366
+ finally { addButton.disabled = false; }
367
+ };
368
+ document.getElementById('reload').onclick = () => { if (selected) reloadProfile(selected.accountIdx); };
369
+ document.getElementById('err-retry').onclick = () => { if (selected) reloadProfile(selected.accountIdx); };
370
+ document.getElementById('err-cfg').onclick = () => { const row = selected && rows.get(selected.accountIdx); if (row) row._openCfg(); };
371
+
372
+ new ResizeObserver(() => requestAnimationFrame(syncPreview)).observe(preview);
373
+ window.addEventListener('resize', syncPreview);
374
+ try { window.panelAPI.onCellState(applyCellState); } catch (e) {}
375
+ pullStates({ adopt: true })
376
+ .catch(() => {})
377
+ .then(() => loadProfiles())
378
+ .catch((e) => { flash(e.message || String(e), 8000); render(); });
379
+ </script>
380
+ </body>
381
+ </html>
@@ -30,6 +30,6 @@ test("facebook-matrix page targets facebook and its own cell ids / storage key",
30
30
 
31
31
  test("panel-cells detects Facebook identities and lists them on profiles", () => {
32
32
  const src = read("src/tabbrowser/panel-cells.js");
33
- assert.match(src, /facebookIdentity\.isFacebookUrl\(url\)/);
33
+ assert.match(src, /facebookIdentity\.isFacebookUrl\(u\)/);
34
34
  assert.match(src, /facebook: facebookIdentity\.facebookIdentityFromProfile\(p\),/);
35
35
  });
@@ -18,7 +18,7 @@ test("panel menu offers blank panel and Telegram matrix actions", () => {
18
18
  const opened = [];
19
19
  const template = createPanelMenuTemplate((preset) => opened.push(preset));
20
20
 
21
- assert.deepEqual(template.map((item) => item.label), ["面板", "Telegram 矩阵", "Redroid 矩阵", "Facebook 矩阵"]);
21
+ assert.deepEqual(template.map((item) => item.label), ["面板", "Telegram 矩阵", "Redroid 矩阵", "Facebook 矩阵", "TikTok 矩阵"]);
22
22
  template[0].click();
23
23
  template[1].click();
24
24
  template[2].click();