cicy-desktop 2.1.313 → 2.1.314
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 +1 -1
- package/src/tabbrowser/facebook-identity.js +55 -0
- package/src/tabbrowser/facebook-matrix.html +381 -0
- package/src/tabbrowser/panel-cells.js +11 -4
- package/src/tabbrowser/panel-menu.js +4 -0
- package/src/tabbrowser/panel-page-router.js +1 -0
- package/src/tabbrowser/panel-presets.js +1 -0
- package/test/facebook-identity.test.js +21 -0
- package/test/facebook-matrix.test.js +27 -0
- package/test/panel-launcher.test.js +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Copyright 2026 CiCy AI
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// facebook-identity.js — read "who is logged in" out of a Facebook web session so
|
|
5
|
+
// the Facebook 矩阵 list can show each profile's account name.
|
|
6
|
+
//
|
|
7
|
+
// Facebook embeds the current account in the initial page payload
|
|
8
|
+
// (CurrentUserInitialData: {"ACCOUNT_ID","USER_ID","NAME","SHORT_NAME"}) and the
|
|
9
|
+
// numeric id in the `c_user` cookie. We read only id + display name; nothing
|
|
10
|
+
// here touches session cookies' values beyond the public user id.
|
|
11
|
+
|
|
12
|
+
const FACEBOOK_HOST_RE = /(^|\.)facebook\.com$/i;
|
|
13
|
+
|
|
14
|
+
function isFacebookUrl(url) {
|
|
15
|
+
try { return FACEBOOK_HOST_RE.test(new URL(String(url || "")).hostname); } catch (e) { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FACEBOOK_IDENTITY_SCRIPT = `(() => {
|
|
19
|
+
try {
|
|
20
|
+
const m = document.cookie.match(/(?:^|;\\s*)c_user=(\\d+)/);
|
|
21
|
+
const id = m ? m[1] : "";
|
|
22
|
+
const html = document.documentElement ? document.documentElement.innerHTML : "";
|
|
23
|
+
const name = (html.match(/"NAME":"((?:[^"\\\\]|\\\\.)*)"/) || [])[1] || "";
|
|
24
|
+
const shortName = (html.match(/"SHORT_NAME":"((?:[^"\\\\]|\\\\.)*)"/) || [])[1] || "";
|
|
25
|
+
const uid = id || (html.match(/"USER_ID":"(\\d+)"/) || [])[1] || "";
|
|
26
|
+
if (!uid || uid === "0") return null;
|
|
27
|
+
const dec = (s) => { try { return JSON.parse('"' + s + '"'); } catch (e) { return s; } };
|
|
28
|
+
return { id: uid, displayName: dec(name), shortName: dec(shortName) };
|
|
29
|
+
} catch (e) { return null; }
|
|
30
|
+
})()`;
|
|
31
|
+
|
|
32
|
+
function normalizeFacebookIdentity(raw) {
|
|
33
|
+
if (!raw || typeof raw !== "object") return null;
|
|
34
|
+
const s = (v) => (typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "");
|
|
35
|
+
const id = s(raw.id);
|
|
36
|
+
if (!id || id === "0") return null;
|
|
37
|
+
return { id, username: s(raw.username), displayName: s(raw.displayName) || s(raw.shortName), phone: "" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Profile-store login record (keyed by name "facebook").
|
|
41
|
+
function facebookLoginRecord(identity) {
|
|
42
|
+
const it = normalizeFacebookIdentity(identity);
|
|
43
|
+
if (!it) return null;
|
|
44
|
+
return { url: "https://www.facebook.com", name: "facebook", username: it.id, mobile: "", note: it.displayName };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Reverse: identity view from a stored profile (for list rendering).
|
|
48
|
+
function facebookIdentityFromProfile(profile) {
|
|
49
|
+
const logins = profile && Array.isArray(profile.logins) ? profile.logins : [];
|
|
50
|
+
const l = logins.find((x) => String((x && x.name) || "").toLowerCase() === "facebook");
|
|
51
|
+
if (!l) return null;
|
|
52
|
+
return { id: String(l.username || ""), username: "", displayName: String(l.note || ""), phone: "" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { isFacebookUrl, FACEBOOK_IDENTITY_SCRIPT, normalizeFacebookIdentity, facebookLoginRecord, facebookIdentityFromProfile };
|
|
@@ -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>Facebook 矩阵</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>Facebook 矩阵 <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」创建第一个 Facebook 会话</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="重新加载 Facebook">重新加载</button></div>
|
|
104
|
+
</div>
|
|
105
|
+
<div id="phone">
|
|
106
|
+
<div id="phone-preview"></div>
|
|
107
|
+
<div class="overlay on" id="ov-empty"><div>选择左侧一个 Profile 预览 Facebook</div></div>
|
|
108
|
+
<div class="overlay" id="ov-loading"><div><div class="spinner"></div><div>正在加载 Facebook…</div></div></div>
|
|
109
|
+
<div class="overlay" id="ov-error">
|
|
110
|
+
<div>
|
|
111
|
+
<h2>无法连接 Facebook</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.facebook.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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
137
|
+
const cellId = (idx) => `facebook-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.facebook || 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> 不可用或无法访问 Facebook。${desc}<br>点 ⚙ 检查或更换该 Profile 的代理,可用「测 IP」验证。`;
|
|
202
|
+
return `当前为直连,网络无法访问 Facebook。${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 会一并清除它的 Facebook 登录态和缓存。</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('facebook-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 = /^facebook-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 = /^facebook-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('facebook-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('facebook-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>
|
|
@@ -47,6 +47,7 @@ function profileForCell(url, requested) {
|
|
|
47
47
|
return Number.isInteger(idx) && idx > 0 ? idx : DEFAULT_PROFILE;
|
|
48
48
|
}
|
|
49
49
|
const telegramIdentity = require("./telegram-identity");
|
|
50
|
+
const facebookIdentity = require("./facebook-identity");
|
|
50
51
|
const appliedProxy = new Set(); // partitions whose proxy is already configured
|
|
51
52
|
function ensureCellSessionProxy(idx) {
|
|
52
53
|
const part = partitionFor(idx);
|
|
@@ -228,20 +229,25 @@ class PanelCells {
|
|
|
228
229
|
const detectIdentity = () => {
|
|
229
230
|
if (profileIdx === 0) return;
|
|
230
231
|
let url = ""; try { url = wc.getURL(); } catch (e) {}
|
|
231
|
-
|
|
232
|
+
// 站点 → 身份模块(Telegram Web K / Facebook);其他站点不探。
|
|
233
|
+
const site = telegramIdentity.isTelegramUrl(url) ? telegramIdentity : facebookIdentity.isFacebookUrl(url) ? facebookIdentity : null;
|
|
234
|
+
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;
|
|
232
238
|
const seq = ++identSeq;
|
|
233
239
|
const delays = [2500, 8000, 20000, 45000];
|
|
234
240
|
const tick = async (i) => {
|
|
235
241
|
if (seq !== identSeq || wc.isDestroyed()) return;
|
|
236
242
|
let raw = null;
|
|
237
|
-
try { raw = await wc.executeJavaScript(
|
|
238
|
-
const it =
|
|
243
|
+
try { raw = await wc.executeJavaScript(script, true); } catch (e) {}
|
|
244
|
+
const it = normalize(raw);
|
|
239
245
|
if (seq !== identSeq) return;
|
|
240
246
|
if (it && (it.username || it.displayName || it.phone)) {
|
|
241
247
|
const prev = rec.identity;
|
|
242
248
|
rec.identity = it;
|
|
243
249
|
if (!prev || prev.username !== it.username || prev.displayName !== it.displayName || prev.phone !== it.phone) {
|
|
244
|
-
try { require("../profiles/profile-store").setLogin("electron", profileIdx,
|
|
250
|
+
try { require("../profiles/profile-store").setLogin("electron", profileIdx, record(it)); } catch (e) {}
|
|
245
251
|
this.sendState({ id: cellId, wcId: wc.id, identity: it, loading: false, url, title: (() => { try { return wc.getTitle(); } catch (e) { return ""; } })() });
|
|
246
252
|
}
|
|
247
253
|
return;
|
|
@@ -393,6 +399,7 @@ function installIpc(findTab) {
|
|
|
393
399
|
proxy: p.proxy && p.proxy.enabled ? String(p.proxy.url || "") : "",
|
|
394
400
|
note: String(p.note || ""),
|
|
395
401
|
telegram: telegramIdentity.telegramIdentityFromProfile(p),
|
|
402
|
+
facebook: facebookIdentity.facebookIdentityFromProfile(p),
|
|
396
403
|
ipInfo: p.ipInfo && p.ipInfo.ip ? { ip: String(p.ipInfo.ip), area: String(p.ipInfo.area || ""), probedAt: String(p.ipInfo.probedAt || "") } : null,
|
|
397
404
|
}));
|
|
398
405
|
} catch (err) { return []; }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const PRESETS = {
|
|
2
2
|
"telegram-matrix": { preset: "telegram-matrix", title: "Telegram 矩阵", query: "preset=telegram-matrix" },
|
|
3
3
|
"redroid-matrix": { preset: "redroid-matrix", title: "Redroid 矩阵", query: "preset=redroid-matrix" },
|
|
4
|
+
"facebook-matrix": { preset: "facebook-matrix", title: "Facebook 矩阵", query: "preset=facebook-matrix" },
|
|
4
5
|
};
|
|
5
6
|
|
|
6
7
|
function resolvePanelPreset(value) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const fb = require("../src/tabbrowser/facebook-identity");
|
|
4
|
+
|
|
5
|
+
test("facebook url detection", () => {
|
|
6
|
+
assert.equal(fb.isFacebookUrl("https://www.facebook.com/"), true);
|
|
7
|
+
assert.equal(fb.isFacebookUrl("https://m.facebook.com/home.php"), true);
|
|
8
|
+
assert.equal(fb.isFacebookUrl("https://web.telegram.org/k/"), false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("facebook identity normalizes and round-trips through the login record", () => {
|
|
12
|
+
const it = fb.normalizeFacebookIdentity({ id: "100012345", displayName: "Zhang San", shortName: "Zhang" });
|
|
13
|
+
assert.deepEqual(it, { id: "100012345", username: "", displayName: "Zhang San", phone: "" });
|
|
14
|
+
const rec = fb.facebookLoginRecord(it);
|
|
15
|
+
assert.equal(rec.name, "facebook");
|
|
16
|
+
assert.equal(rec.username, "100012345");
|
|
17
|
+
assert.equal(rec.note, "Zhang San");
|
|
18
|
+
assert.deepEqual(fb.facebookIdentityFromProfile({ logins: [rec] }), { id: "100012345", username: "", displayName: "Zhang San", phone: "" });
|
|
19
|
+
assert.equal(fb.normalizeFacebookIdentity({ id: "0" }), null);
|
|
20
|
+
assert.equal(fb.facebookIdentityFromProfile({ logins: [] }), null);
|
|
21
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const read = (p) => fs.readFileSync(path.join(__dirname, "..", p), "utf8");
|
|
6
|
+
|
|
7
|
+
test("facebook-matrix preset is registered everywhere the telegram one is", () => {
|
|
8
|
+
assert.match(read("src/tabbrowser/panel-presets.js"), /"facebook-matrix": \{ preset: "facebook-matrix", title: "Facebook 矩阵"/);
|
|
9
|
+
assert.match(read("src/tabbrowser/panel-page-router.js"), /"facebook-matrix": "facebook-matrix\.html"/);
|
|
10
|
+
assert.match(read("src/tabbrowser/panel-menu.js"), /label: "Facebook 矩阵"/);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("facebook-matrix page targets facebook and its own cell ids / storage key", () => {
|
|
14
|
+
const html = read("src/tabbrowser/facebook-matrix.html");
|
|
15
|
+
assert.match(html, /https:\/\/www\.facebook\.com\//);
|
|
16
|
+
assert.match(html, /facebook-preview-\$\{idx\}/);
|
|
17
|
+
assert.match(html, /facebook-matrix-profile/);
|
|
18
|
+
assert.doesNotMatch(html, /telegram/i);
|
|
19
|
+
assert.match(html, /id="rows"/);
|
|
20
|
+
assert.match(html, /panelAPI\.states/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("panel-cells detects Facebook identities and lists them on profiles", () => {
|
|
24
|
+
const src = read("src/tabbrowser/panel-cells.js");
|
|
25
|
+
assert.match(src, /facebookIdentity\.isFacebookUrl\(url\)/);
|
|
26
|
+
assert.match(src, /facebook: facebookIdentity\.facebookIdentityFromProfile\(p\),/);
|
|
27
|
+
});
|
|
@@ -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 矩阵"]);
|
|
21
|
+
assert.deepEqual(template.map((item) => item.label), ["面板", "Telegram 矩阵", "Redroid 矩阵", "Facebook 矩阵"]);
|
|
22
22
|
template[0].click();
|
|
23
23
|
template[1].click();
|
|
24
24
|
template[2].click();
|