@glittering0717/ai-browser 2.1.2 → 2.1.4
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/scripts/release.cjs +45 -9
- package/src/main/page_manager.js +50 -3
- package/src/renderer/loading.html +75 -0
- package/src/renderer/tab_bar.html +100 -27
- package/src/shared/version.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glittering0717/ai-browser",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.4",
|
|
4
4
|
"description": "AI-native browser — renders pixels for humans, structured semantic-tree API for agents. No screenshots, no OCR, no coordinate guessing.",
|
|
5
5
|
"main": "src/main/index.js",
|
|
6
6
|
"bin": {
|
package/scripts/release.cjs
CHANGED
|
@@ -48,6 +48,14 @@ function run(cmd, argv, opts = {}) {
|
|
|
48
48
|
function git(...argv) { return run('git', argv); }
|
|
49
49
|
function gitOut(...argv) { return run('git', argv).stdout.trim(); }
|
|
50
50
|
|
|
51
|
+
// registry 的 github 登录可以吃环境变量,从而绕开 device flow ——
|
|
52
|
+
// device flow 在无 TTY / 后台执行时会静默卡在 "Logging in..." 不打印 URL,
|
|
53
|
+
// 看起来像卡死,实际是在等一个永远不会被打开的浏览器。
|
|
54
|
+
function ghToken() {
|
|
55
|
+
try { return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim(); }
|
|
56
|
+
catch { return ''; }
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
const done = [];
|
|
52
60
|
function mark(m) { done.push(m); }
|
|
53
61
|
|
|
@@ -160,8 +168,15 @@ if (npm.status === 0) {
|
|
|
160
168
|
mark('npm publish');
|
|
161
169
|
} else {
|
|
162
170
|
const out = `${npm.stdout || ''}${npm.stderr || ''}`;
|
|
163
|
-
//
|
|
164
|
-
|
|
171
|
+
// 三层认证失败的表现各不相同,逐条给可执行的下一步(2026-10-23 一天踩完):
|
|
172
|
+
// 401 / E404 on PUT → token 过期或被撤销(npm 用 404 掩盖"没权限")
|
|
173
|
+
// 403 + "bypass 2fa" → token 有效但建的时候没勾 Bypass 2FA
|
|
174
|
+
// EPUBLISHCONFLICT → 版本号已被占用
|
|
175
|
+
if (/bypass 2fa enabled is required/i.test(out)) {
|
|
176
|
+
fail('npm token 没有开 Bypass 2FA —— 账号开了 2FA 时,granular token 必须勾这个开关才能发布');
|
|
177
|
+
console.log(' https://www.npmjs.com/settings/glittering0717/tokens/new');
|
|
178
|
+
console.log(' Permissions: Read and write | Scope: @glittering0717 | 勾 Bypass 2FA');
|
|
179
|
+
} else if (/E401|E404|Unauthorized|Not found/i.test(out)) {
|
|
165
180
|
fail('npm 认证失效(401/404)。需要重新登录一次,之后本脚本可全自动:');
|
|
166
181
|
console.log(' npm login # 浏览器登录');
|
|
167
182
|
console.log(' 或:把新的 granular access token 写进 ~/.npmrc 的 //registry.npmjs.org/:_authToken');
|
|
@@ -181,17 +196,38 @@ if (!publisher) {
|
|
|
181
196
|
warn('未找到 mcp-publisher,跳过。安装:gh release download --repo modelcontextprotocol/registry \\');
|
|
182
197
|
console.log(' --pattern "mcp-publisher_darwin_arm64.tar.gz" && tar xzf ...');
|
|
183
198
|
} else {
|
|
184
|
-
|
|
185
|
-
|
|
199
|
+
// registry 的登录态是一枚短期 JWT(几小时就过期)。过期表现是 401
|
|
200
|
+
// "token has invalid claims: token is expired" —— 不是"没登录",
|
|
201
|
+
// 所以 `login` 会直接成功但不会有人去重跑它。这里自动续一次:
|
|
202
|
+
// MCP_GITHUB_TOKEN 让 login 走非交互路径(device flow 在无 TTY 时会静默卡住)。
|
|
203
|
+
const doPublish = () => spawnSync(publisher, ['publish'], { cwd: ROOT, encoding: 'utf8' });
|
|
204
|
+
let pub = doPublish();
|
|
205
|
+
let pubOut = `${pub.stdout || ''}${pub.stderr || ''}`;
|
|
206
|
+
if (pub.status !== 0 && /expired|invalid or expired|status 401/i.test(pubOut)) {
|
|
207
|
+
warn('registry 登录已过期,自动续期一次…');
|
|
208
|
+
const relogin = spawnSync(publisher, ['login', 'github'], {
|
|
209
|
+
cwd: ROOT,
|
|
210
|
+
encoding: 'utf8',
|
|
211
|
+
env: { ...process.env, MCP_GITHUB_TOKEN: ghToken() },
|
|
212
|
+
});
|
|
213
|
+
const reloginOut = `${relogin.stdout || ''}${relogin.stderr || ''}`;
|
|
214
|
+
if (relogin.status === 0 && /logged in/i.test(reloginOut)) {
|
|
215
|
+
ok('已重新登录');
|
|
216
|
+
pub = doPublish();
|
|
217
|
+
pubOut = `${pub.stdout || ''}${pub.stderr || ''}`;
|
|
218
|
+
} else {
|
|
219
|
+
warn('自动续期失败(需要 gh CLI 已登录):' + reloginOut.trim().split('\n').pop());
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (pub.status === 0 && /successfully published/i.test(pubOut)) {
|
|
186
223
|
ok('MCP registry 已发布');
|
|
187
224
|
mark('mcp registry');
|
|
188
225
|
} else {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
warn('需要先登录一次:mcp-publisher login github(device flow,点一下浏览器)');
|
|
226
|
+
fail('MCP registry 发布失败:' + pubOut.split('\n').filter(Boolean).slice(-6).join(' | '));
|
|
227
|
+
if (/was not found \(status: 404\)/i.test(pubOut)) {
|
|
228
|
+
warn('registry 要求 npm 上先有该版本 —— 先把 npm 那步修好,再重跑本脚本');
|
|
193
229
|
}
|
|
194
|
-
warn('
|
|
230
|
+
warn('补救:mcp-publisher publish');
|
|
195
231
|
}
|
|
196
232
|
}
|
|
197
233
|
|
package/src/main/page_manager.js
CHANGED
|
@@ -191,6 +191,11 @@ class PageManager {
|
|
|
191
191
|
// 垫片登记完再导航,否则真实文档先到、垫片只对"下一次文档"生效,那个 tab
|
|
192
192
|
// 就成了没有垫片的窗中窗(见 newTab 末尾长注释)。
|
|
193
193
|
this._windowOpenShimReady = new Map();
|
|
194
|
+
// tabId -> 'pending' | 'loading' | 'ready' | 'blank':这个 tab 现在处于导航的
|
|
195
|
+
// 哪一步。存在的理由:新 tab 在占位页(原先是纯白 about:blank)停留时,
|
|
196
|
+
// 光看 URL 分不清"正在加载"和"卡住了/开错了"。listTabs 把它报出去,
|
|
197
|
+
// agent 才知道该等而不是该重试。
|
|
198
|
+
this._tabNavState = new Map();
|
|
194
199
|
this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
|
|
195
200
|
this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
|
|
196
201
|
this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
|
|
@@ -3271,6 +3276,8 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3271
3276
|
const wc0 = view.webContents;
|
|
3272
3277
|
const targetUrl = url || null;
|
|
3273
3278
|
if (wc0 && !wc0.isDestroyed()) {
|
|
3279
|
+
// 还没发起真实导航:停在占位页等垫片注入。
|
|
3280
|
+
this._tabNavState.set(tabId, 'pending');
|
|
3274
3281
|
// 垫片就绪信号:先挂上去,再去加载 about:blank。顺序不能反 ——
|
|
3275
3282
|
// ai:window_open_nav 可能在这一行之后立刻到达(IPC 保序,但它不等我们)。
|
|
3276
3283
|
let signalReady = () => {};
|
|
@@ -3281,19 +3288,38 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3281
3288
|
if (proceeded) return;
|
|
3282
3289
|
proceeded = true;
|
|
3283
3290
|
Promise.resolve(this._ensureWindowOpenShim(tabId, view)).finally(() => {
|
|
3284
|
-
// 失败也放行:宁可这个 tab
|
|
3291
|
+
// 失败也放行:宁可这个 tab 没有垫片,也不能把它卡在占位页。
|
|
3285
3292
|
signalReady();
|
|
3286
3293
|
if (targetUrl && wc0 && !wc0.isDestroyed()) {
|
|
3294
|
+
// 让"正在加载"可被观测:白屏最贵的地方不是慢,是慢得无法和
|
|
3295
|
+
// "开错了"区分(见 listTabs 的 state 字段)。
|
|
3296
|
+
this._tabNavState.set(tabId, 'loading');
|
|
3297
|
+
wc0.once('did-finish-load', () => {
|
|
3298
|
+
if (this._tabNavState.get(tabId) === 'loading') this._tabNavState.set(tabId, 'ready');
|
|
3299
|
+
});
|
|
3287
3300
|
wc0.loadURL(targetUrl).catch(() => {});
|
|
3301
|
+
} else {
|
|
3302
|
+
// 无目标 URL 的占位 tab(window.open("") 的老站模式):它本来就
|
|
3303
|
+
// 要停在这里等站点赋 location,不算"卡住",但也不是就绪。
|
|
3304
|
+
this._tabNavState.set(tabId, 'blank');
|
|
3288
3305
|
}
|
|
3289
3306
|
});
|
|
3290
3307
|
};
|
|
3291
|
-
//
|
|
3308
|
+
// 兜底:占位页的 did-finish-load 万一来不了(renderer 异常、
|
|
3292
3309
|
// 视图尚未 attach 等),也不能把真实导航永久挂住——1.2s 后照常放行,
|
|
3293
3310
|
// 最坏退化成"首屏没有垫片"(与修复前一致),而不是打不开页面。
|
|
3294
3311
|
const guard = setTimeout(proceed, 1200);
|
|
3295
3312
|
if (guard.unref) guard.unref();
|
|
3296
|
-
|
|
3313
|
+
// ⚠️ 占位页用 loading.html 而不是 about:blank(2026-10-24 修)。
|
|
3314
|
+
//
|
|
3315
|
+
// 这段等待原本是纯白的 about:blank。快站约 4s,慢站实测 70s(GitHub
|
|
3316
|
+
// OAuth 直到 ERR_CONNECTION_TIMED_OUT 才切到错误页)—— 用户和 agent
|
|
3317
|
+
// 看到的都是"弹出来一个空白页面",和"垫片没把导航发出去"那个老 bug
|
|
3318
|
+
// (超星"进入学习")在现象上完全一样,无法区分。
|
|
3319
|
+
// loading.html 只做展示(目标地址 + 转圈),不发请求不跳转,真实导航
|
|
3320
|
+
// 仍由上面的 proceed 发起。无目标 URL 的占位 tab(window.open("") 的
|
|
3321
|
+
// 老站模式)保持 about:blank:它本来就要停在这里等 location 赋值。
|
|
3322
|
+
wc0.loadURL(this._loadingPlaceholderUrl(targetUrl)).catch(() => {});
|
|
3297
3323
|
wc0.once('did-finish-load', () => { clearTimeout(guard); proceed(); });
|
|
3298
3324
|
}
|
|
3299
3325
|
|
|
@@ -3524,6 +3550,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3524
3550
|
}
|
|
3525
3551
|
this._windowOpenShimTabs.delete(tabId);
|
|
3526
3552
|
this._windowOpenShimReady.delete(tabId);
|
|
3553
|
+
this._tabNavState.delete(tabId);
|
|
3527
3554
|
// Release per-tab network resources so a closed tab can never leak its
|
|
3528
3555
|
// debugger attachment or cached request entries / stale monitors.
|
|
3529
3556
|
this._cleanupTabResources(tabId);
|
|
@@ -3579,6 +3606,12 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3579
3606
|
url: view.webContents.getURL(),
|
|
3580
3607
|
title: view.webContents.getTitle(),
|
|
3581
3608
|
active: id === this.activeTab,
|
|
3609
|
+
// 'pending' = 停在占位页等脚本注入(真实导航还没发起)
|
|
3610
|
+
// 'loading' = 真实导航已发起、还没完成 —— 这时候页面多半还是空的,
|
|
3611
|
+
// 是"正在打开"而不是"开错了",等它,不要重试
|
|
3612
|
+
// 'blank' = window.open("") 的占位 tab,在等站点赋 location
|
|
3613
|
+
// 'ready' = 已就绪(没有记录时也按就绪处理)
|
|
3614
|
+
state: this._tabNavState.get(id) || 'ready',
|
|
3582
3615
|
});
|
|
3583
3616
|
}
|
|
3584
3617
|
return list;
|
|
@@ -3968,6 +4001,20 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3968
4001
|
// load about:blank,did-finish-load 后再调这里。
|
|
3969
4002
|
// 幂等:每个 tab 只注册一次(_windowOpenShimTabs 记账);跨导航自动保留
|
|
3970
4003
|
// 正是 addScriptToEvaluateOnNewDocument 的语义,不需要重复注册。
|
|
4004
|
+
// 新 tab 的占位页 URL:有目标地址时用 loading.html(把白屏变成可诊断的
|
|
4005
|
+
// "正在打开 <url>"),无目标地址时保持 about:blank(window.open("") 的
|
|
4006
|
+
// 占位 tab 要停在这里等站点赋 location,不能显示成"正在打开")。
|
|
4007
|
+
_loadingPlaceholderUrl(targetUrl) {
|
|
4008
|
+
if (!targetUrl) return 'about:blank';
|
|
4009
|
+
try {
|
|
4010
|
+
const pagePath = path.join(__dirname, '../renderer/loading.html');
|
|
4011
|
+
const q = new URLSearchParams({ url: String(targetUrl) });
|
|
4012
|
+
return 'file://' + pagePath + '?' + q.toString();
|
|
4013
|
+
} catch (_e) {
|
|
4014
|
+
return 'about:blank';
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
|
|
3971
4018
|
_ensureWindowOpenShim(tabId, view) {
|
|
3972
4019
|
if (this._windowOpenShimTabs.has(tabId)) return Promise.resolve(true);
|
|
3973
4020
|
this._windowOpenShimTabs.add(tabId);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>正在打开…</title>
|
|
6
|
+
<!--
|
|
7
|
+
loading.html — 新标签页的加载占位页。
|
|
8
|
+
|
|
9
|
+
为什么需要它:新建 tab 时必须先 load 一个真实文档才能注入 window.open 垫片
|
|
10
|
+
(CDP 命令在未导航的空 webContents 上无人处理会挂住),注入完成后才导航到
|
|
11
|
+
真实 URL。这段间隔原本是 `about:blank` —— 也就是一片纯白。网络慢时它能持续
|
|
12
|
+
几十秒(实测 GitHub OAuth 70s 才 ERR_CONNECTION_TIMED_OUT),用户和 agent
|
|
13
|
+
看到的都是"弹出来一个空白页面",无法区分"正在加载"和"开错了"。
|
|
14
|
+
|
|
15
|
+
这个页面本身不发请求、不跑脚本,只把"正在打开哪个地址"显示出来,把白屏
|
|
16
|
+
变成一个可诊断的状态。它是纯展示:真实导航由主进程在垫片注入完成后发起。
|
|
17
|
+
-->
|
|
18
|
+
<style>
|
|
19
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
20
|
+
html, body { height: 100%; }
|
|
21
|
+
body {
|
|
22
|
+
background: #1e1e1e;
|
|
23
|
+
color: #d4d4d4;
|
|
24
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
|
|
25
|
+
font-size: 13px;
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
padding: 24px;
|
|
30
|
+
}
|
|
31
|
+
.box { max-width: 560px; width: 100%; }
|
|
32
|
+
.row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
|
33
|
+
.spinner {
|
|
34
|
+
width: 14px; height: 14px; flex: none;
|
|
35
|
+
border: 2px solid #3c3c3c;
|
|
36
|
+
border-top-color: #569cd6;
|
|
37
|
+
border-radius: 50%;
|
|
38
|
+
animation: spin 0.8s linear infinite;
|
|
39
|
+
}
|
|
40
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
41
|
+
h1 { font-size: 14px; font-weight: 600; color: #d4d4d4; }
|
|
42
|
+
.url {
|
|
43
|
+
background: #252526; border: 1px solid #3c3c3c; border-radius: 4px;
|
|
44
|
+
padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
45
|
+
font-size: 12px; color: #9cdcfd; word-break: break-all; margin-bottom: 14px;
|
|
46
|
+
min-height: 34px;
|
|
47
|
+
}
|
|
48
|
+
.hint { color: #858585; font-size: 12px; line-height: 1.7; }
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<div class="box">
|
|
53
|
+
<div class="row">
|
|
54
|
+
<div class="spinner"></div>
|
|
55
|
+
<h1>正在打开…</h1>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="url" id="u">about:blank</div>
|
|
58
|
+
<div class="hint">
|
|
59
|
+
新标签页需要先准备好脚本注入环境,再跳转目标地址。<br>
|
|
60
|
+
长时间停在这里通常意味着目标地址连接超时(常见于被墙或缓慢的第三方登录)。
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
<script>
|
|
64
|
+
// 只做展示:把目标地址从 query 里取出来显示。
|
|
65
|
+
// 不发起任何请求、不做跳转 —— 真实导航由主进程发起,避免和它抢。
|
|
66
|
+
(function () {
|
|
67
|
+
try {
|
|
68
|
+
var q = new URLSearchParams(location.search);
|
|
69
|
+
var u = q.get('url') || '';
|
|
70
|
+
if (u) document.getElementById('u').textContent = u;
|
|
71
|
+
} catch (e) {}
|
|
72
|
+
})();
|
|
73
|
+
</script>
|
|
74
|
+
</body>
|
|
75
|
+
</html>
|
|
@@ -18,9 +18,24 @@
|
|
|
18
18
|
}
|
|
19
19
|
/* ---- 第一行:tab 条 ---- */
|
|
20
20
|
#tabs-row { display: flex; align-items: stretch; height: 36px; flex: none; }
|
|
21
|
-
|
|
21
|
+
/* 标签超过可视宽度时必须能横向滚动,否则后面的标签点不到、也关不掉。
|
|
22
|
+
原来是 overflow:hidden —— 超出部分被裁掉且没有任何滚动途径,标签一多
|
|
23
|
+
就等于"后半截标签永久不可用"。 */
|
|
24
|
+
#tabs {
|
|
25
|
+
display: flex; flex: 1; min-width: 0;
|
|
26
|
+
overflow-x: auto; overflow-y: hidden;
|
|
27
|
+
-webkit-app-region: no-drag;
|
|
28
|
+
}
|
|
29
|
+
/* 细滚动条:既提示"还有更多标签",又不占视觉高度 */
|
|
30
|
+
#tabs::-webkit-scrollbar { height: 3px; }
|
|
31
|
+
#tabs::-webkit-scrollbar-track { background: transparent; }
|
|
32
|
+
#tabs::-webkit-scrollbar-thumb { background: #3a3a3a; border-radius: 2px; }
|
|
33
|
+
#tabs::-webkit-scrollbar-thumb:hover { background: #4d4d4d; }
|
|
22
34
|
.tab {
|
|
23
35
|
display: flex; align-items: center;
|
|
36
|
+
/* flex:none:不参与收缩。否则标签会被一路压到 min-width 再溢出,
|
|
37
|
+
配合滚动后应该保持各自宽度、让容器去滚。 */
|
|
38
|
+
flex: none;
|
|
24
39
|
max-width: 200px; min-width: 80px; padding: 0 12px; height: 36px;
|
|
25
40
|
cursor: pointer; border-right: 1px solid #2a2a2a;
|
|
26
41
|
transition: background 0.15s;
|
|
@@ -91,7 +106,15 @@
|
|
|
91
106
|
</div>
|
|
92
107
|
|
|
93
108
|
<script>
|
|
94
|
-
|
|
109
|
+
// ipcRenderer 在 Electron 主窗口里才有。直接用浏览器/测试页打开本文件时
|
|
110
|
+
// require 会抛错——以前这会让整个 <script> 中断,连布局和滚动监听都不生效
|
|
111
|
+
// (UI 完全死掉)。现在降级成空操作:交互没了,但标签栏仍然看得见、滚得动。
|
|
112
|
+
let ipcRenderer = null;
|
|
113
|
+
try { ({ ipcRenderer } = require('electron')); } catch (e) { /* 非 Electron 环境 */ }
|
|
114
|
+
function send(ch, payload) { try { ipcRenderer.send(ch, payload); } catch (e) {} }
|
|
115
|
+
function invoke(ch, payload) {
|
|
116
|
+
return ipcRenderer ? ipcRenderer.invoke(ch, payload) : Promise.reject(new Error('no ipc'));
|
|
117
|
+
}
|
|
95
118
|
|
|
96
119
|
const $ = (id) => document.getElementById(id);
|
|
97
120
|
const tabsEl = $('tabs'), addrEl = $('addr'), statusEl = $('status');
|
|
@@ -115,28 +138,78 @@ function applyNavState(s) {
|
|
|
115
138
|
addrEl.style.borderColor = s.loading ? '#e0a33e' : '';
|
|
116
139
|
}
|
|
117
140
|
|
|
141
|
+
let lastIds = ''; // 上次渲染的 tab id 序列 —— 只有它变了才重建 DOM
|
|
142
|
+
let lastActiveId = null;
|
|
143
|
+
|
|
144
|
+
// 把指定 tab 滚进可视区(只在它确实在视区外时才动,避免无谓跳动)。
|
|
145
|
+
// 用 getBoundingClientRect 而不是 offsetLeft:后者依赖 offsetParent,
|
|
146
|
+
// 在 flex + 滚动容器里算出来的是错的值。
|
|
147
|
+
function scrollTabIntoView(id) {
|
|
148
|
+
const el = tabsEl.querySelector('.tab[data-id="' + id + '"]');
|
|
149
|
+
if (!el) return;
|
|
150
|
+
const cr = tabsEl.getBoundingClientRect(), er = el.getBoundingClientRect();
|
|
151
|
+
if (er.left < cr.left) tabsEl.scrollLeft -= (cr.left - er.left) + 8;
|
|
152
|
+
else if (er.right > cr.right) tabsEl.scrollLeft += (er.right - cr.right) + 8;
|
|
153
|
+
}
|
|
154
|
+
|
|
118
155
|
async function renderTabs() {
|
|
119
156
|
try {
|
|
120
|
-
const tabs = await
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
157
|
+
const tabs = await invoke('tab:list');
|
|
158
|
+
const ids = tabs.map(function (t) { return t.id; }).join(',');
|
|
159
|
+
|
|
160
|
+
if (ids !== lastIds) {
|
|
161
|
+
// 结构变了(增/删 tab):重建 DOM,但必须保住横向滚动位置 ——
|
|
162
|
+
// 否则在右端关掉一个标签后视图会跳回最左,后面的标签又摸不着了。
|
|
163
|
+
const prevScroll = tabsEl.scrollLeft;
|
|
164
|
+
tabsEl.innerHTML = '';
|
|
165
|
+
for (const tab of tabs) {
|
|
166
|
+
const el = document.createElement('div');
|
|
167
|
+
el.className = 'tab' + (tab.active ? ' active' : '');
|
|
168
|
+
el.dataset.id = tab.id;
|
|
169
|
+
el.innerHTML = '<span class="tab-id">[' + tab.id + ']</span>' +
|
|
170
|
+
'<span class="tab-title">' + escapeHtml((tab.title || tab.url || 'New Tab').slice(0, 30)) + '</span>' +
|
|
171
|
+
'<span class="tab-close" data-id="' + tab.id + '">×</span>';
|
|
172
|
+
el.addEventListener('click', function (e) {
|
|
173
|
+
// 从 this.dataset 取 id,不用闭包里的 tab —— 增量更新时闭包会过期。
|
|
174
|
+
const id = parseInt(this.dataset.id);
|
|
175
|
+
if (e.target.classList.contains('tab-close')) send('tab:close', id);
|
|
176
|
+
else send('tab:activate', id);
|
|
177
|
+
});
|
|
178
|
+
tabsEl.appendChild(el);
|
|
179
|
+
}
|
|
180
|
+
lastIds = ids;
|
|
181
|
+
tabsEl.scrollLeft = prevScroll;
|
|
182
|
+
} else {
|
|
183
|
+
// 结构没变:只改标题和激活态。绝不重建 —— 这里每 1.5s 就跑一次,
|
|
184
|
+
// 一旦重建,用户刚滚到的位置就被重置,标签一多就永远滚不回去。
|
|
185
|
+
for (let i = 0; i < tabs.length && i < tabsEl.children.length; i++) {
|
|
186
|
+
const t = tabs[i], el = tabsEl.children[i];
|
|
187
|
+
el.classList.toggle('active', !!t.active);
|
|
188
|
+
const titleEl = el.querySelector('.tab-title');
|
|
189
|
+
const title = (t.title || t.url || 'New Tab').slice(0, 30);
|
|
190
|
+
if (titleEl && titleEl.textContent !== title) titleEl.textContent = title;
|
|
191
|
+
}
|
|
137
192
|
}
|
|
193
|
+
|
|
194
|
+
// 激活的 tab 换人了(含 agent 用 browse_tabs 切的情况)就把它滚进来;
|
|
195
|
+
// 已经可见时上面的计算不会产生位移。
|
|
196
|
+
const active = tabs.find(function (t) { return t.active; });
|
|
197
|
+
const activeId = active ? active.id : null;
|
|
198
|
+
if (activeId !== null && activeId !== lastActiveId) scrollTabIntoView(activeId);
|
|
199
|
+
if (activeId !== null) lastActiveId = activeId;
|
|
138
200
|
} catch (e) { /* ipcRenderer 尚未就绪 */ }
|
|
139
201
|
}
|
|
202
|
+
|
|
203
|
+
// 横向滚动:鼠标滚轮是垂直的,标签栏要的是左右滚(浏览器惯例)。
|
|
204
|
+
// overflow 改成 auto 只是"能滚",没有这一条仍然只能拖滚动条/用触控板横扫。
|
|
205
|
+
tabsEl.addEventListener('wheel', function (e) {
|
|
206
|
+
if (tabsEl.scrollWidth <= tabsEl.clientWidth) return;
|
|
207
|
+
const d = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
|
208
|
+
if (!d) return;
|
|
209
|
+
tabsEl.scrollLeft += d;
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
}, { passive: false });
|
|
212
|
+
|
|
140
213
|
function escapeHtml(s) {
|
|
141
214
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
142
215
|
}
|
|
@@ -147,27 +220,27 @@ function escapeHtml(s) {
|
|
|
147
220
|
// 所以显示数据全部由主进程推送,本地不缓存。
|
|
148
221
|
let activeTab = null;
|
|
149
222
|
|
|
150
|
-
ipcRenderer.on('nav:state', (_e, s) => applyNavState(s));
|
|
223
|
+
if (ipcRenderer) ipcRenderer.on('nav:state', (_e, s) => applyNavState(s));
|
|
151
224
|
|
|
152
|
-
backBtn.addEventListener('click', () =>
|
|
153
|
-
fwdBtn.addEventListener('click', () =>
|
|
154
|
-
reloadBtn.addEventListener('click', () =>
|
|
155
|
-
stopBtn.addEventListener('click', () =>
|
|
225
|
+
backBtn.addEventListener('click', () => send('nav:back'));
|
|
226
|
+
fwdBtn.addEventListener('click', () => send('nav:forward'));
|
|
227
|
+
reloadBtn.addEventListener('click', () => send('nav:reload'));
|
|
228
|
+
stopBtn.addEventListener('click', () => send('nav:stop'));
|
|
156
229
|
|
|
157
230
|
addrEl.addEventListener('keydown', (e) => {
|
|
158
231
|
if (e.key !== 'Enter') return;
|
|
159
232
|
const v = addrEl.value.trim();
|
|
160
233
|
if (!v) return;
|
|
161
234
|
addrEl.blur();
|
|
162
|
-
|
|
235
|
+
send('nav:go', v);
|
|
163
236
|
});
|
|
164
237
|
// 聚焦时全选,方便直接输入覆盖(浏览器惯例)
|
|
165
238
|
addrEl.addEventListener('focus', () => addrEl.select());
|
|
166
239
|
|
|
167
|
-
$('new-tab').addEventListener('click', () =>
|
|
240
|
+
$('new-tab').addEventListener('click', () => send('tab:new'));
|
|
168
241
|
|
|
169
242
|
// 初始拉一次状态,然后 tab 条照旧定时刷新
|
|
170
|
-
|
|
243
|
+
invoke('nav:state').then((s) => { if (s) applyNavState(s); }).catch(() => {});
|
|
171
244
|
renderTabs();
|
|
172
245
|
setInterval(renderTabs, 1500);
|
|
173
246
|
</script>
|
package/src/shared/version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// Kept in JS (not imported from package.json) to avoid Node import-attribute
|
|
3
3
|
// (JSON module) compatibility issues inside Electron. package.json "version"
|
|
4
4
|
// is manually synced to this value; tests assert they match.
|
|
5
|
-
export const VERSION = '2.1.
|
|
5
|
+
export const VERSION = '2.1.4';
|