cicy-desktop 2.1.321 → 2.1.322

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.321",
3
+ "version": "2.1.322",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -144,10 +144,10 @@
144
144
  "//optionalDependencies": "(2026-06 回调): mac/linux 改回 native cicy-code(:8008,colima 在 16G mac 上压垮内存)→ 重新内置 cicy-code-<plat> / cicy-mihomo-<plat>,localbin.fromBundle 零网络 seed(npm 仅作更新通道,带 npmmirror→npmjs 回退)。这些由 scripts/sync-runtime-deps.cjs 在 tag-push 时同步到最新版。npm 的 os/cpu 字段保证每个平台只装自己那份。Windows 仍走 docker(WSL :8009),它那份 cicy-code-windows 用不到但 bundle 着无害。",
145
145
  "optionalDependencies": {
146
146
  "electron": "41.0.3",
147
- "cicy-code-darwin-x64": "2.3.589",
148
- "cicy-code-darwin-arm64": "2.3.589",
149
- "cicy-code-linux-x64": "2.3.589",
150
- "cicy-code-linux-arm64": "2.3.589",
147
+ "cicy-code-darwin-x64": "2.3.591",
148
+ "cicy-code-darwin-arm64": "2.3.591",
149
+ "cicy-code-linux-x64": "2.3.591",
150
+ "cicy-code-linux-arm64": "2.3.591",
151
151
  "cicy-code-windows-x64": "2.3.193",
152
152
  "cicy-mihomo-darwin-x64": "1.10.4",
153
153
  "cicy-mihomo-darwin-arm64": "1.10.4",
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env bash
2
+ # Superseded by the cicy-exe-deploy skill (cicy-skills/skills/cicy-exe-deploy) — kept as a plain-ssh fallback.
2
3
  # Push a locally built CiCy Desktop installer to every Windows node and install
3
4
  # it there — no CDN, no auto-update wait.
4
5
  #
5
6
  # scripts/deploy-exe-fleet.sh [path/to/CiCy Desktop Setup X.Y.Z.exe] [node...]
6
7
  #
7
8
  # Per node (ssh alias from ~/.ssh/config, e.g. xs-1001 — frp via ws-hub):
8
- # 1. scp the exe into the node's ~/projects (= C:\ of its Windows host)
9
+ # 1. scp the exe into the node's ~/projects (= C:\projects of its Windows host)
9
10
  # 2. ssh -L tunnel to the node's cicy-code :8008, ask it for connected
10
11
  # cicy-desktop clients (agent-desktop clients)
11
12
  # 3. agent-desktop exec-file a tiny .bat on each Windows client that runs the
@@ -24,12 +25,12 @@ fi
24
25
  shift || true
25
26
  VER=$(basename "$EXE" | sed -E 's/.*Setup ([0-9.]+)\.exe/\1/')
26
27
  REMOTE_NAME="CiCy-Desktop-Setup-$VER.exe"
27
- WIN_PATH="C:\\$REMOTE_NAME"
28
+ WIN_PATH="C:\\projects\\$REMOTE_NAME"
28
29
 
29
30
  if [ $# -gt 0 ]; then NODES="$*"; fi
30
31
  if [ -z "${NODES:-}" ]; then
31
32
  # every xs-* alias in ~/.ssh/config (Windows hosts); mac-local etc. are not exe targets
32
- NODES=$(awk '/^Host xs-/{print $2}' ~/.ssh/config | sort -V)
33
+ NODES=$(awk '/^Host xs-/ && $2 !~ /[*?]/{print $2}' ~/.ssh/config | sort -V)
33
34
  fi
34
35
  PORT=${LOCAL_PORT_BASE:-18100}
35
36
  SCRATCH=$(mktemp -d)
@@ -62,20 +62,72 @@ function desktopInstanceId() {
62
62
  return id;
63
63
  }
64
64
 
65
- async function hubFetch(route, { method = "GET", token = "", body = null } = {}) {
65
+ // Electron's net.fetch goes through Chromium's network stack it honours the
66
+ // OS proxy settings (PAC / system proxy) like the renderer does. Node's global
67
+ // fetch ignores them, which showed up as "fetch failed" on PCs that can only
68
+ // reach the hub through a proxy. Falls back to global fetch outside Electron.
69
+ function pickFetch() {
70
+ try { const { net, app } = require("electron"); if (net && typeof net.fetch === "function" && app && app.isReady()) return net.fetch.bind(net); } catch {}
71
+ return fetch;
72
+ }
73
+
74
+ async function hubFetch(route, { method = "GET", token = "", body = null, tries = 2 } = {}) {
75
+ let lastErr;
76
+ for (let attempt = 0; attempt < tries; attempt++) {
77
+ const ctrl = new AbortController();
78
+ const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
79
+ try {
80
+ const headers = { accept: "application/json" };
81
+ if (token) headers.authorization = "Bearer " + token;
82
+ if (body != null) headers["content-type"] = "application/json";
83
+ const r = await pickFetch()(hubOrigin() + route, { method, headers, body: body == null ? undefined : JSON.stringify(body), signal: ctrl.signal, cache: "no-store" });
84
+ const text = await r.text();
85
+ let json = null;
86
+ try { json = text ? JSON.parse(text) : null; } catch {}
87
+ return { status: r.status, ok: r.ok, json, text };
88
+ } catch (e) {
89
+ lastErr = e;
90
+ log.warn(`[hub] ${method} ${route} failed (${attempt + 1}/${tries}): ${e.message}`);
91
+ if (attempt + 1 < tries) await new Promise((r) => setTimeout(r, 1500));
92
+ } finally { clearTimeout(t); }
93
+ }
94
+ const msg = String((lastErr && lastErr.message) || lastErr || "fetch failed");
95
+ throw new Error(/fetch failed|ECONN|ENOTFOUND|abort/i.test(msg) ? `hub unreachable (${hubOrigin()}): ${msg}` : msg);
96
+ }
97
+
98
+ // ── fallback: the local cicy-code sidecar ───────────────────────────────────
99
+ // Some PCs cannot reach the hub directly at all (no proxy on Windows) while the
100
+ // cicy-code container on the same PC can (it carries its own proxy) and is
101
+ // signed in to the same hub account. Its /api/im/cicy-cloud/* routes give us
102
+ // the same instance list and one-time open grants, so use them when the direct
103
+ // call dies with a network error.
104
+ const SIDECAR_PORT = Number(process.env.CICY_SIDECAR_PORT || 8008);
105
+ let _sidecarTok = { value: "", at: 0 };
106
+ async function sidecarToken() {
107
+ if (_sidecarTok.value && Date.now() - _sidecarTok.at < 5 * 60 * 1000) return _sidecarTok.value;
108
+ let tok = "";
109
+ try { tok = String(readGlobalConfig(GLOBAL_JSON)?.api_token || "").trim(); } catch {}
110
+ if (!tok && process.platform === "win32") {
111
+ try { tok = String(await require("../sidecar/wsl-docker").readContainerToken(SIDECAR_PORT) || "").trim(); } catch (e) { log.warn(`[hub] sidecar token: ${e.message}`); }
112
+ }
113
+ if (tok) _sidecarTok = { value: tok, at: Date.now() };
114
+ return tok;
115
+ }
116
+ async function sidecarFetch(route, { method = "GET", body = null } = {}) {
117
+ const tok = await sidecarToken();
118
+ if (!tok) throw new Error("local cicy-code token unavailable");
66
119
  const ctrl = new AbortController();
67
120
  const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
68
121
  try {
69
- const headers = { accept: "application/json" };
70
- if (token) headers.authorization = "Bearer " + token;
122
+ const headers = { accept: "application/json", authorization: "Bearer " + tok };
71
123
  if (body != null) headers["content-type"] = "application/json";
72
- const r = await fetch(hubOrigin() + route, { method, headers, body: body == null ? undefined : JSON.stringify(body), signal: ctrl.signal, cache: "no-store" });
124
+ const r = await fetch(`http://127.0.0.1:${SIDECAR_PORT}${route}`, { method, headers, body: body == null ? undefined : JSON.stringify(body), signal: ctrl.signal, cache: "no-store" });
73
125
  const text = await r.text();
74
- let json = null;
75
- try { json = text ? JSON.parse(text) : null; } catch {}
126
+ let json = null; try { json = text ? JSON.parse(text) : null; } catch {}
76
127
  return { status: r.status, ok: r.ok, json, text };
77
128
  } finally { clearTimeout(t); }
78
129
  }
130
+ const isNetErr = (e) => /unreachable|fetch failed|ECONN|ENOTFOUND|abort/i.test(String((e && e.message) || e));
79
131
 
80
132
  function errorOf(res, fallback) {
81
133
  return (res && res.json && (res.json.error || res.json.message)) || fallback || `HTTP ${res && res.status}`;
@@ -168,18 +220,27 @@ function clearAuth() {
168
220
  async function instances() {
169
221
  const a = readAuth();
170
222
  if (!a) return { ok: false, error: "not_logged_in", instances: [] };
171
- const res = await hubFetch("/api/instances", { token: a.token });
172
- if (res.status === 401) { clearAuth(); return { ok: false, error: "unauthorized", instances: [] }; }
223
+ let res, viaSidecar = false;
224
+ try { res = await hubFetch("/api/instances", { token: a.token }); }
225
+ catch (e) {
226
+ if (!isNetErr(e)) return { ok: false, error: e.message, instances: [] };
227
+ log.warn(`[hub] direct instance list failed (${e.message}); trying the local cicy-code`);
228
+ try { res = await sidecarFetch("/api/im/cicy-cloud/instances"); viaSidecar = true; }
229
+ catch (e2) { return { ok: false, error: `${e.message}; sidecar: ${e2.message}`, instances: [] }; }
230
+ }
231
+ if (res.status === 401 && !viaSidecar) { clearAuth(); return { ok: false, error: "unauthorized", instances: [] }; }
173
232
  if (!res.ok || !res.json) return { ok: false, error: errorOf(res, "instances_failed"), instances: [] };
174
233
  const list = Array.isArray(res.json.instances) ? res.json.instances : [];
175
234
  const out = list
176
- .filter((i) => !i.self && !String(i.platform || "").startsWith("desktop"))
235
+ // direct: `self` is this desktop's hidden pseudo-instance; via sidecar: `self` is the
236
+ // local node itself, which IS a real instance the user may want to open.
237
+ .filter((i) => (viaSidecar || !i.self) && !String(i.platform || "").startsWith("desktop"))
177
238
  .map((i) => ({
178
239
  id: i.instanceId,
179
- name: i.name || i.proxyHost || i.instanceId,
240
+ name: i.name || (i.proxyHost ? String(i.proxyHost).split(".")[0] : i.instanceId),
180
241
  host: i.proxyHost || "",
181
242
  url: i.proxyHost ? "https://" + i.proxyHost : "",
182
- online: !!i.online,
243
+ online: i.status ? i.status === "online" : !!i.online,
183
244
  reachable: !!i.proxyAvailable,
184
245
  version: i.version || "",
185
246
  platform: i.platform || "",
@@ -192,14 +253,22 @@ async function instances() {
192
253
  agents: Array.isArray(i.agents) ? i.agents.length : undefined,
193
254
  }))
194
255
  .sort((x, y) => Number(y.online) - Number(x.online) || x.name.localeCompare(y.name));
195
- return { ok: true, owner: res.json.owner || a.owner, instances: out };
256
+ return { ok: true, owner: res.json.owner || a.owner, viaSidecar, instances: out };
196
257
  }
197
258
 
198
259
  // One-time hand-off URL for an instance (optionally one of its local ports).
199
260
  async function grantUrl({ id, port = 0, next = "/" } = {}) {
200
261
  const a = readAuth();
201
262
  if (!a) throw new Error("not_logged_in");
202
- const res = await hubFetch("/api/gateway/grant", { method: "POST", token: a.token, body: { instanceId: String(id || ""), port: Number(port) || 0, next: String(next || "/") } });
263
+ let res;
264
+ try { res = await hubFetch("/api/gateway/grant", { method: "POST", token: a.token, body: { instanceId: String(id || ""), port: Number(port) || 0, next: String(next || "/") } }); }
265
+ catch (e) {
266
+ if (!isNetErr(e)) throw e;
267
+ log.warn(`[hub] direct grant failed (${e.message}); trying the local cicy-code`);
268
+ res = await sidecarFetch("/api/im/cicy-cloud/open", { method: "POST", body: { instance_id: String(id || ""), port: Number(port) || 0, next: String(next || "/") } });
269
+ if (!res.ok || !res.json || !res.json.url) throw new Error(errorOf(res, "grant_failed"));
270
+ return { url: res.json.url, host: res.json.host };
271
+ }
203
272
  if (res.status === 401) { clearAuth(); throw new Error("unauthorized"); }
204
273
  if (!res.ok || !res.json || !res.json.url) throw new Error(errorOf(res, "grant_failed"));
205
274
  return { url: res.json.url, host: res.json.host };
@@ -40,26 +40,22 @@
40
40
  .line2 .tg b { color: var(--fg); font-weight: 600; }
41
41
  .line2 .tg.none { color: #6b6b72; }
42
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; }
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; }
44
+ .line2 .ip.none { display: none; }
45
+ /* 代理设置弹窗 */
46
+ .modal { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.55); }
47
+ .modal[hidden] { display: none; }
48
+ .modal-card { width: 380px; max-width: 92vw; background: var(--cell); border: 1px solid var(--line); border-radius: 14px; padding: 18px; box-shadow: 0 24px 70px rgba(0,0,0,.5); }
49
+ .modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; font-size: 15px; font-weight: 650; }
50
+ .modal-head .icon { background: transparent; color: #b4b4bb; padding: 2px 6px; font-size: 14px; border-radius: 6px; }
51
+ .modal-head .icon:hover { background: var(--hover); color: #fff; }
52
+ .modal-label { display: block; font-size: 12px; color: var(--muted); margin: 4px 0 6px; }
53
+ .modal-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
54
+ .modal-row input { flex: 1; min-width: 0; height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 10px; background: #111113; color: var(--fg); font: 12px ui-monospace, monospace; outline: 0; }
55
+ .modal-row input:focus { border-color: var(--accent); }
56
+ .modal-ip { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #b4b4bb; }
57
+ .modal-err { color: #ff8a8a; font-size: 12px; min-height: 16px; margin-bottom: 4px; word-break: break-all; }
58
+ .modal-danger { margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
63
59
  button.del { background: #b3261e; }
64
60
  .row-err { padding: 4px 0 0 16px; color: #ff8a8a; font-size: 11px; display: none; }
65
61
  .row-err.on { display: block; }
@@ -73,7 +69,7 @@
73
69
  #status { color: var(--muted); white-space: nowrap; }
74
70
  .actions { display: flex; gap: 6px; flex: none; }
75
71
  .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); }
72
+ #phone { position: relative; width: min(410px, calc(100% - 20px)); flex: 1 1 auto; 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
73
  #phone-preview { width: 100%; height: 100%; overflow: hidden; border-radius: 19px; background: #fff; }
78
74
  .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
75
  .overlay.on { display: grid; }
@@ -95,12 +91,12 @@
95
91
  <div id="rows"></div>
96
92
  <div id="empty">还没有 Profile<br>点击右上角「添加 Profile」创建第一个 Telegram 会话</div>
97
93
  </div>
98
- <div class="hint">点击行切换预览;点代理地址可修改(留空 = 直连)。已打开过的会话会在后台保持登录。</div>
94
+ <div class="hint">点击行切换预览;点 设置代理 / 测 IP / 删除。已打开过的会话会在后台保持登录。</div>
99
95
  </aside>
100
96
  <main>
101
97
  <div id="preview-title">
102
98
  <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="重新加载 Telegram Web">重新加载</button></div>
99
+ <div class="actions"><button class="ghost" id="probe-ip" title="经该 Profile 的代理探测出口 IP / 地区">测 IP</button><button class="ghost" id="reload" title="重新加载 Telegram Web">重新加载</button></div>
104
100
  </div>
105
101
  <div id="phone">
106
102
  <div id="phone-preview"></div>
@@ -115,6 +111,16 @@
115
111
  </div>
116
112
  </div>
117
113
  </main>
114
+ <div id="cfg-modal" class="modal" hidden>
115
+ <div class="modal-card">
116
+ <div class="modal-head"><span id="cfg-title">Profile 设置</span><button class="icon" id="cfg-x" title="关闭">✕</button></div>
117
+ <label class="modal-label">代理地址</label>
118
+ <div class="modal-row"><input id="cfg-proxy" placeholder="留空 = 直连,例如 http://127.0.0.1:20001" spellcheck="false"><button id="cfg-save">保存</button></div>
119
+ <div class="modal-row"><button class="ghost" id="cfg-test">测 IP</button><span class="modal-ip" id="cfg-ip"></span></div>
120
+ <div class="modal-err" id="cfg-err"></div>
121
+ <div class="modal-danger"><button class="ghost del" id="cfg-del">删除此 Profile</button></div>
122
+ </div>
123
+ </div>
118
124
  <script>
119
125
  const TELEGRAM_URL = 'https://web.telegram.org/k/';
120
126
  const rowsEl = document.getElementById('rows');
@@ -139,25 +145,25 @@
139
145
  const dotClass = (st) => st.loading ? 'loading' : st.failed ? 'failed' : (st.url ? 'ok' : '');
140
146
  const stateText = (st, opened) => st.loading ? '加载中…' : st.failed ? '连接失败' : st.url ? '已加载' : (opened ? '' : '未打开');
141
147
  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) {
148
+ // 列表只显示一个简短地区(取最具体的一段:优先省/州,否则国家),完整信息在 tooltip。
149
+ const shortArea = (area) => { const seg = String(area || '').split('·').map((x) => x.trim()).filter(Boolean); return seg[1] || seg[0] || ''; };
150
+ function renderIp(p) {
143
151
  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; }
152
+ const el = row.querySelector('.ip'); el.className = 'ip';
148
153
  const info = p.ipInfo;
149
154
  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 / 地区'; }
155
+ const a = shortArea(info.area);
156
+ el.textContent = a || info.ip;
157
+ el.title = `出口 IP ${info.ip}${info.area ? ',' + info.area : ''},${fmtAgo(info.probedAt) || '未知时间'}探测`;
158
+ } else { el.classList.add('none'); el.textContent = ''; el.title = ''; }
153
159
  }
160
+ // 经该 Profile 的代理探测出口 IP;成功后更新它的列表地区。返回 {ok, info|error}。
154
161
  async function probeIp(p) {
155
- renderIp(p, 'busy');
156
162
  try {
157
163
  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) }); }
164
+ if (r && r.ipInfo) { p.ipInfo = r.ipInfo; renderIp(p); return { ok: true, info: r.ipInfo }; }
165
+ return { ok: false, error: (r && r.error) || '探测失败' };
166
+ } catch (e) { return { ok: false, error: e.message || String(e) }; }
161
167
  }
162
168
  // 身份:优先用本次会话探到的(cellState.identity),否则用 profile 里存的(上次登录)。
163
169
  const identityOf = (p) => stateOf(p.accountIdx).identity || p.telegram || null;
@@ -225,46 +231,9 @@
225
231
  row.className = 'row'; row.dataset.id = String(p.accountIdx); row.title = `persist:sandbox-${p.accountIdx}`;
226
232
  row.innerHTML = `
227
233
  <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 会一并清除它的 Telegram 登录态和缓存。</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' }); };
234
+ <div class="line2"><span class="tg none">—</span><span class="ip none"></span></div>`;
235
+ // ⚙ 打开设置弹窗(代理 / 测 IP / 删除),不再 inline。
236
+ row.querySelector('.gear').onclick = (e) => { e.stopPropagation(); openCfgModal(p); };
268
237
  row.onclick = () => selectProfile(p.accountIdx);
269
238
  row.querySelector('.reload').onclick = (e) => { e.stopPropagation(); reloadProfile(p.accountIdx); };
270
239
  return row;
@@ -367,7 +336,63 @@
367
336
  };
368
337
  document.getElementById('reload').onclick = () => { if (selected) reloadProfile(selected.accountIdx); };
369
338
  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(); };
339
+ document.getElementById('err-cfg').onclick = () => { if (selected) openCfgModal(selected); };
340
+ document.getElementById('probe-ip').onclick = async () => {
341
+ if (!selected) { flash('先选择一个 Profile'); return; }
342
+ const b = document.getElementById('probe-ip'); const o = b.textContent; b.disabled = true; b.textContent = '测 IP…';
343
+ const r = await probeIp(selected);
344
+ b.disabled = false; b.textContent = o;
345
+ flash(r.ok ? `#${selected.accountIdx} 出口:${shortArea(selected.ipInfo.area) || selected.ipInfo.ip}` : `IP 探测失败:${r.error}`, 6000);
346
+ };
347
+ // ── 设置弹窗(代理 / 测 IP / 删除) ──
348
+ const cfgModal = document.getElementById('cfg-modal');
349
+ const cfgProxy = document.getElementById('cfg-proxy');
350
+ const cfgErr = document.getElementById('cfg-err');
351
+ const cfgIp = document.getElementById('cfg-ip');
352
+ const cfgDel = document.getElementById('cfg-del');
353
+ let cfgP = null, delArmed = false;
354
+ const cfgIpText = (p) => p && p.ipInfo && p.ipInfo.ip ? (p.ipInfo.area ? `${p.ipInfo.area} · ${p.ipInfo.ip}` : p.ipInfo.ip) : '未探测';
355
+ function openCfgModal(p) {
356
+ if (!p) return; cfgP = p; delArmed = false;
357
+ document.getElementById('cfg-title').textContent = `${p.name} · #${p.accountIdx} 设置`;
358
+ cfgProxy.value = p.proxy || ''; cfgErr.textContent = ''; cfgIp.textContent = cfgIpText(p);
359
+ cfgDel.textContent = '删除此 Profile'; cfgDel.disabled = false;
360
+ cfgModal.hidden = false; cfgProxy.focus();
361
+ // BrowserView 预览永远浮在页面 DOM 之上,会盖住弹窗 —— 打开弹窗时把 cells 脱离(复用拖拽逻辑),关闭时贴回。
362
+ try { window.panelAPI.dragging(true); } catch (e) {}
363
+ }
364
+ function closeCfgModal() {
365
+ cfgModal.hidden = true; cfgP = null; delArmed = false;
366
+ try { window.panelAPI.dragging(false); } catch (e) {}
367
+ syncPreview();
368
+ }
369
+ cfgModal.onclick = (e) => { if (e.target === cfgModal) closeCfgModal(); };
370
+ document.getElementById('cfg-x').onclick = closeCfgModal;
371
+ document.getElementById('cfg-save').onclick = async () => {
372
+ if (!cfgP) return; const b = document.getElementById('cfg-save'); b.disabled = true; cfgErr.textContent = '';
373
+ try {
374
+ const u = await window.panelAPI.setProfileProxy(cfgP.accountIdx, cfgProxy.value.trim());
375
+ cfgP.proxy = u.proxy; cfgP.ipInfo = null; renderIp(cfgP);
376
+ if (openedProfiles.has(cfgP.accountIdx)) { reloadProfile(cfgP.accountIdx); flash(`#${cfgP.accountIdx} 代理已应用,重新加载中`); }
377
+ else flash(`#${cfgP.accountIdx} 代理已保存,打开时生效`);
378
+ closeCfgModal();
379
+ } catch (e) { cfgErr.textContent = e.message || String(e); } finally { b.disabled = false; }
380
+ };
381
+ cfgProxy.onkeydown = (e) => { if (e.key === 'Enter') document.getElementById('cfg-save').onclick(); if (e.key === 'Escape') closeCfgModal(); };
382
+ document.getElementById('cfg-test').onclick = async () => {
383
+ if (!cfgP) return; const b = document.getElementById('cfg-test'); const o = b.textContent; b.disabled = true; b.textContent = '测 IP…'; cfgErr.textContent = '';
384
+ const r = await probeIp(cfgP);
385
+ b.disabled = false; b.textContent = o;
386
+ if (r.ok) cfgIp.textContent = cfgIpText(cfgP); else cfgErr.textContent = '探测失败:' + r.error;
387
+ };
388
+ cfgDel.onclick = async () => {
389
+ if (!cfgP) return;
390
+ if (!delArmed) { delArmed = true; cfgDel.textContent = '确认删除(不可恢复)'; setTimeout(() => { if (delArmed) { delArmed = false; cfgDel.textContent = '删除此 Profile'; } }, 4000); return; }
391
+ delArmed = false; cfgDel.disabled = true; cfgErr.textContent = '';
392
+ try { await removeProfile(cfgP); closeCfgModal(); }
393
+ catch (e) { cfgErr.textContent = e.message || String(e); cfgDel.disabled = false; cfgDel.textContent = '删除此 Profile'; }
394
+ };
395
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !cfgModal.hidden) closeCfgModal(); });
371
396
 
372
397
  new ResizeObserver(() => requestAnimationFrame(syncPreview)).observe(preview);
373
398
  window.addEventListener('resize', syncPreview);
@@ -88,13 +88,13 @@ test("matrix page exposes profile table, phone preview, proxy drawer and ip prob
88
88
  assert.match(html, /panelAPI\.setProfileProxy/);
89
89
  assert.match(html, /panelAPI\.probeIp/);
90
90
  assert.match(html, /panelAPI\.removeProfile/);
91
- // 删除必须二次确认:确认块默认隐藏,只有 rm-yes 才真正调用删除
92
- assert.match(html, /\.cfg \.confirm \{ display: none;/);
93
- assert.match(html, /querySelector\('\.rm-yes'\)\.onclick = async[^]*await removeProfile\(p\)/);
94
- assert.doesNotMatch(html.slice(html.indexOf("querySelector('.rm').onclick"), html.indexOf("querySelector('.rm-no')")), /removeProfile\(/);
91
+ // 删除在设置弹窗里二次确认(delArmed):第一次点只 arm,第二次才真正 removeProfile
92
+ assert.match(html, /id="cfg-del"/);
93
+ assert.match(html, /if \(!delArmed\) \{ delArmed = true;[^]*return; \}/);
94
+ assert.match(html, /delArmed = false;[^]*await removeProfile\(cfgP\)/);
95
95
  assert.match(html, /id="add-profile"/);
96
- assert.match(html, /class="cfg"[^]*data-role="proxy"/);
97
- assert.match(html, /\.row\.cfg-open \.cfg \{ display: block; \}/);
96
+ assert.match(html, /id="cfg-modal"/);
97
+ assert.match(html, /openCfgModal\(/);
98
98
  assert.match(html, /class="tg none"/);
99
99
  assert.match(html, /id="phone-preview"/);
100
100
  assert.match(html, /https:\/\/web\.telegram\.org\/k\//);