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.
|
|
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.
|
|
148
|
-
"cicy-code-darwin-arm64": "2.3.
|
|
149
|
-
"cicy-code-linux-x64": "2.3.
|
|
150
|
-
"cicy-code-linux-arm64": "2.3.
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
44
|
-
.line2 .ip.none {
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
.
|
|
49
|
-
.
|
|
50
|
-
.
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
.
|
|
56
|
-
.
|
|
57
|
-
.
|
|
58
|
-
.
|
|
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));
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
el.
|
|
152
|
-
|
|
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
|
-
|
|
160
|
-
} catch (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"
|
|
229
|
-
|
|
230
|
-
|
|
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 = () => {
|
|
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
|
-
//
|
|
92
|
-
assert.match(html,
|
|
93
|
-
assert.match(html, /
|
|
94
|
-
assert.
|
|
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, /
|
|
97
|
-
assert.match(html,
|
|
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\//);
|