@glittering0717/ai-browser 1.2.1 → 1.3.0
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/README.md +155 -20
- package/bin/ai-browser.cjs +1 -1
- package/examples/AGENT_INSTRUCTIONS.md +22 -2
- package/llms-install.md +14 -4
- package/package.json +9 -5
- package/scripts/build_dmg.cjs +220 -0
- package/scripts/marketing/data_snapshot.sh +69 -0
- package/scripts/render_icon.cjs +118 -0
- package/src/main/axExtractor.js +103 -36
- package/src/main/error_log.js +245 -0
- package/src/main/index.js +487 -16
- package/src/main/mcp_server.js +39 -1
- package/src/main/mcp_tools.js +64 -10
- package/src/main/network_monitor.js +86 -23
- package/src/main/page_manager.js +1040 -115
- package/src/main/ws_server.js +127 -16
- package/src/preload/actions.cjs +61 -2
- package/src/preload/bridge.cjs +31 -1
- package/src/preload/extractor.cjs +520 -25
- package/src/renderer/error_banner.html +90 -0
- package/src/renderer/error_page.html +125 -0
- package/src/renderer/tab_bar.html +116 -56
- package/src/shared/act_params.cjs +53 -0
- package/src/shared/auth_wall.cjs +162 -0
- package/src/shared/config.js +8 -2
- package/src/shared/deadline.js +6 -0
- package/src/shared/deep_active.cjs +31 -0
- package/src/shared/error_banner.cjs +43 -0
- package/src/shared/find_ref.cjs +44 -0
- package/src/shared/frame_probe.cjs +112 -0
- package/src/shared/hit_point.cjs +80 -0
- package/src/shared/input_verify.js +47 -0
- package/src/shared/key_dispatch.cjs +37 -0
- package/src/shared/nav_wait.cjs +47 -0
- package/src/shared/protocol.js +15 -0
- package/src/shared/ref_guard.cjs +133 -0
- package/src/shared/screenshot_opts.cjs +130 -0
- package/src/shared/tree_diff.cjs +105 -0
- package/src/shared/version.js +1 -1
- package/tools/coverage_probe.cjs +239 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>主进程异常</title>
|
|
6
|
+
<style>
|
|
7
|
+
/* 横幅只有 34px 高:所有尺寸都按"一行文字"设计,任何换行都会被裁掉,
|
|
8
|
+
所以主进程那边已经把消息压成一行(shared/error_banner.cjs)。 */
|
|
9
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
10
|
+
html, body { height: 100%; overflow: hidden; }
|
|
11
|
+
body {
|
|
12
|
+
display: flex; align-items: center; gap: 10px;
|
|
13
|
+
padding: 0 8px 0 12px;
|
|
14
|
+
background: #7f1d1d;
|
|
15
|
+
color: #fff;
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
|
|
17
|
+
font-size: 12px;
|
|
18
|
+
user-select: none;
|
|
19
|
+
cursor: default;
|
|
20
|
+
border-top: 1px solid #b91c1c;
|
|
21
|
+
}
|
|
22
|
+
#icon { font-size: 13px; flex: none; opacity: 0.95; }
|
|
23
|
+
#title { font-weight: 600; flex: none; letter-spacing: 0.2px; }
|
|
24
|
+
#detail {
|
|
25
|
+
flex: 1 1 auto;
|
|
26
|
+
min-width: 0;
|
|
27
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
28
|
+
color: #fee2e2;
|
|
29
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
30
|
+
font-size: 11.5px;
|
|
31
|
+
}
|
|
32
|
+
#count {
|
|
33
|
+
display: none;
|
|
34
|
+
flex: none;
|
|
35
|
+
background: #fff; color: #7f1d1d;
|
|
36
|
+
border-radius: 8px; padding: 0 6px;
|
|
37
|
+
font-size: 10.5px; font-weight: 700; line-height: 16px;
|
|
38
|
+
}
|
|
39
|
+
#log {
|
|
40
|
+
flex: 0 1 auto; max-width: 34%;
|
|
41
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
42
|
+
color: #fca5a5; font-size: 11px;
|
|
43
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
44
|
+
}
|
|
45
|
+
#close {
|
|
46
|
+
flex: none;
|
|
47
|
+
background: transparent; border: 1px solid #fca5a5; color: #fee2e2;
|
|
48
|
+
border-radius: 4px; width: 20px; height: 20px; line-height: 1;
|
|
49
|
+
cursor: pointer; font-size: 13px; font-family: inherit;
|
|
50
|
+
}
|
|
51
|
+
#close:hover { background: #b91c1c; color: #fff; }
|
|
52
|
+
</style>
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
<span id="icon">⚠</span>
|
|
56
|
+
<span id="title">主进程异常</span>
|
|
57
|
+
<span id="detail"></span>
|
|
58
|
+
<span id="count"></span>
|
|
59
|
+
<span id="log"></span>
|
|
60
|
+
<button id="close" title="关闭(日志已保存,不受影响)">×</button>
|
|
61
|
+
|
|
62
|
+
<script>
|
|
63
|
+
const { ipcRenderer } = require('electron');
|
|
64
|
+
|
|
65
|
+
function apply(p) {
|
|
66
|
+
if (!p) return;
|
|
67
|
+
const detail = document.getElementById('detail');
|
|
68
|
+
// title 只用一次:横幅里"主进程异常"当标签,异常消息当正文,不重复两遍
|
|
69
|
+
document.getElementById('title').textContent = p.title || '主进程异常';
|
|
70
|
+
detail.textContent = p.detail || '';
|
|
71
|
+
detail.title = p.detail || ''; // 悬停看全文(截断时可读)
|
|
72
|
+
const logEl = document.getElementById('log');
|
|
73
|
+
// 路径可能很长,展示时只留尾部(文件名一定在末尾),完整路径放 title
|
|
74
|
+
logEl.textContent = p.log_path ? '日志 ' + p.log_path : '';
|
|
75
|
+
logEl.title = p.log_path || '';
|
|
76
|
+
const countEl = document.getElementById('count');
|
|
77
|
+
if (p.count > 1) {
|
|
78
|
+
countEl.style.display = 'inline-block';
|
|
79
|
+
countEl.textContent = '×' + p.count;
|
|
80
|
+
countEl.title = '连续异常次数(日志里每一条都在)';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ipcRenderer.on('error-banner:show', (_e, payload) => apply(payload));
|
|
85
|
+
document.getElementById('close').addEventListener('click', () => {
|
|
86
|
+
ipcRenderer.send('error-banner:close');
|
|
87
|
+
});
|
|
88
|
+
</script>
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>页面无法打开</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
html, body { height: 100%; }
|
|
9
|
+
body {
|
|
10
|
+
background: #1e1e1e;
|
|
11
|
+
color: #d4d4d4;
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
|
|
13
|
+
font-size: 13px;
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: flex-start;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
padding: 48px 24px;
|
|
18
|
+
}
|
|
19
|
+
.box { max-width: 560px; width: 100%; }
|
|
20
|
+
h1 { font-size: 17px; font-weight: 600; color: #f48771; margin-bottom: 4px; }
|
|
21
|
+
.sub { color: #888; font-size: 12px; margin-bottom: 20px; }
|
|
22
|
+
.url {
|
|
23
|
+
background: #252526; border: 1px solid #3c3c3c; border-radius: 4px;
|
|
24
|
+
padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
25
|
+
font-size: 12px; color: #9cdcfd; word-break: break-all; margin-bottom: 16px;
|
|
26
|
+
}
|
|
27
|
+
.reason { margin-bottom: 20px; line-height: 1.6; }
|
|
28
|
+
.reason b { color: #ce9178; font-weight: 600; }
|
|
29
|
+
.hint { color: #858585; font-size: 12px; line-height: 1.7; margin-bottom: 24px; }
|
|
30
|
+
.hint li { margin-left: 18px; margin-bottom: 4px; }
|
|
31
|
+
.row { display: flex; gap: 8px; }
|
|
32
|
+
button {
|
|
33
|
+
background: #0e639c; color: #fff; border: none; border-radius: 4px;
|
|
34
|
+
padding: 7px 16px; font-size: 12px; cursor: pointer; font-family: inherit;
|
|
35
|
+
}
|
|
36
|
+
button:hover { background: #1177bb; }
|
|
37
|
+
button.ghost { background: #3a3d41; }
|
|
38
|
+
button.ghost:hover { background: #4a4d51; }
|
|
39
|
+
input {
|
|
40
|
+
flex: 1; background: #3c3c3c; border: 1px solid #3c3c3c; border-radius: 4px;
|
|
41
|
+
color: #d4d4d4; padding: 7px 10px; font-size: 12px; font-family: inherit;
|
|
42
|
+
min-width: 0;
|
|
43
|
+
}
|
|
44
|
+
input:focus { outline: none; border-color: #0e639c; }
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<div class="box">
|
|
49
|
+
<h1>这个页面打不开</h1>
|
|
50
|
+
<div class="sub">网络请求失败,浏览器没有拿到任何内容</div>
|
|
51
|
+
|
|
52
|
+
<div class="url" id="url"></div>
|
|
53
|
+
|
|
54
|
+
<div class="reason" id="reason"></div>
|
|
55
|
+
|
|
56
|
+
<div class="hint">
|
|
57
|
+
常见原因:
|
|
58
|
+
<li>网络不通,或 DNS 解析失败</li>
|
|
59
|
+
<li>站点本身不可达 / 已下线</li>
|
|
60
|
+
<li>公司网络或代理拦截了该域名</li>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="row">
|
|
64
|
+
<button id="retry">重试</button>
|
|
65
|
+
<button id="back" class="ghost">返回上一页</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="row" style="margin-top:8px">
|
|
68
|
+
<input id="go" type="text" placeholder="输入网址后按回车打开">
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<script>
|
|
73
|
+
// 主进程通过 query 把失败信息带进来。这个页面本身也在 renderer 里跑,
|
|
74
|
+
// 但它是**我们自己**的页面,不是站点内容 —— 所以人能在这里手动重试 /
|
|
75
|
+
// 改网址,这正是产品承诺的"随时可以手动介入"。
|
|
76
|
+
var q = new URLSearchParams(location.search);
|
|
77
|
+
var failedUrl = q.get('url') || '';
|
|
78
|
+
var errCode = q.get('code') || '';
|
|
79
|
+
var errText = q.get('text') || '';
|
|
80
|
+
document.getElementById('url').textContent = failedUrl || '(未知网址)';
|
|
81
|
+
|
|
82
|
+
var REASONS = {
|
|
83
|
+
'-3': '网络中断(ERR_INTERNET_DISCONNECTED)',
|
|
84
|
+
'-102': '连接被拒绝(ERR_CONNECTION_REFUSED)',
|
|
85
|
+
'-105': 'DNS 解析失败,找不到这个域名(ERR_NAME_NOT_RESOLVED)',
|
|
86
|
+
'-106': '网络不可达(ERR_INTERNET_DISCONNECTED)',
|
|
87
|
+
'-109': '连接超时,站点没有响应(ERR_CONNECTION_TIMED_OUT)',
|
|
88
|
+
'-118': '连接超时(ERR_CONNECTION_TIMED_OUT)',
|
|
89
|
+
'-130': '代理连接失败(ERR_PROXY_CONNECTION_FAILED)',
|
|
90
|
+
'-200': '证书错误,站点证书无效(ERR_CERT_AUTHORITY_INVALID)',
|
|
91
|
+
'-201': '证书已过期(ERR_CERT_DATE_INVALID)',
|
|
92
|
+
'-202': '证书包含错误的主机名(ERR_CERT_COMMON_NAME_INVALID)',
|
|
93
|
+
'-501': '服务器不支持安全连接(ERR_SSL_PROTOCOL_ERROR)',
|
|
94
|
+
'-324': '服务器响应为空(ERR_EMPTY_RESPONSE)',
|
|
95
|
+
'-329': '内容被阻止(ERR_BLOCKED_BY_CLIENT)',
|
|
96
|
+
};
|
|
97
|
+
var r = document.getElementById('reason');
|
|
98
|
+
var known = REASONS[errCode];
|
|
99
|
+
if (known) {
|
|
100
|
+
r.innerHTML = '错误类型:<b>' + known + '</b><br>错误码:' + errCode;
|
|
101
|
+
} else if (errText) {
|
|
102
|
+
r.innerHTML = '错误类型:<b>' + errText.replace(/</g, '<') + '</b><br>错误码:' + errCode;
|
|
103
|
+
} else {
|
|
104
|
+
r.textContent = '错误码:' + (errCode || '(未知)');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 这些按钮要能真的动:走 setWindowOpenHandler 拦不住的本地协议,
|
|
108
|
+
// 所以用 webContents 回主进程的 ipc。
|
|
109
|
+
var { ipcRenderer } = require('electron');
|
|
110
|
+
document.getElementById('retry').addEventListener('click', function () {
|
|
111
|
+
if (failedUrl) ipcRenderer.send('error-page:navigate', failedUrl);
|
|
112
|
+
});
|
|
113
|
+
document.getElementById('back').addEventListener('click', function () {
|
|
114
|
+
ipcRenderer.send('error-page:back');
|
|
115
|
+
});
|
|
116
|
+
var go = document.getElementById('go');
|
|
117
|
+
go.value = failedUrl;
|
|
118
|
+
go.addEventListener('keydown', function (e) {
|
|
119
|
+
if (e.key === 'Enter' && go.value.trim()) {
|
|
120
|
+
ipcRenderer.send('error-page:navigate', go.value.trim());
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
</script>
|
|
124
|
+
</body>
|
|
125
|
+
</html>
|
|
@@ -2,114 +2,174 @@
|
|
|
2
2
|
<html>
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
|
+
<title>AI Browser</title>
|
|
5
6
|
<style>
|
|
6
7
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
html, body { height: 100%; overflow: hidden; }
|
|
7
9
|
body {
|
|
8
|
-
height: 36px;
|
|
9
10
|
background: #1a1a1a;
|
|
10
11
|
display: flex;
|
|
11
|
-
|
|
12
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
|
|
13
14
|
font-size: 12px;
|
|
14
15
|
color: #ccc;
|
|
15
16
|
user-select: none;
|
|
16
17
|
-webkit-app-region: drag;
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
overflow: hidden;
|
|
22
|
-
-webkit-app-region: no-drag;
|
|
23
|
-
}
|
|
19
|
+
/* ---- 第一行:tab 条 ---- */
|
|
20
|
+
#tabs-row { display: flex; align-items: stretch; height: 36px; flex: none; }
|
|
21
|
+
#tabs { display: flex; flex: 1; overflow: hidden; }
|
|
24
22
|
.tab {
|
|
25
|
-
display: flex;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
min-width: 80px;
|
|
29
|
-
padding: 0 12px;
|
|
30
|
-
height: 36px;
|
|
31
|
-
cursor: pointer;
|
|
32
|
-
border-right: 1px solid #2a2a2a;
|
|
23
|
+
display: flex; align-items: center;
|
|
24
|
+
max-width: 200px; min-width: 80px; padding: 0 12px; height: 36px;
|
|
25
|
+
cursor: pointer; border-right: 1px solid #2a2a2a;
|
|
33
26
|
transition: background 0.15s;
|
|
27
|
+
-webkit-app-region: no-drag;
|
|
34
28
|
}
|
|
35
29
|
.tab:hover { background: #2a2a2a; }
|
|
36
30
|
.tab.active { background: #2d2d30; color: #fff; border-bottom: 2px solid #007acc; }
|
|
37
31
|
.tab-title {
|
|
38
|
-
flex: 1;
|
|
39
|
-
overflow: hidden;
|
|
40
|
-
text-overflow: ellipsis;
|
|
41
|
-
white-space: nowrap;
|
|
32
|
+
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
42
33
|
margin-right: 8px;
|
|
43
34
|
}
|
|
44
35
|
.tab-close {
|
|
45
|
-
width: 16px;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
align-items: center;
|
|
49
|
-
justify-content: center;
|
|
50
|
-
border-radius: 3px;
|
|
51
|
-
font-size: 14px;
|
|
52
|
-
color: #666;
|
|
53
|
-
cursor: pointer;
|
|
36
|
+
width: 16px; height: 16px; display: flex; align-items: center;
|
|
37
|
+
justify-content: center; border-radius: 3px; font-size: 14px; color: #666;
|
|
38
|
+
cursor: pointer; flex: none;
|
|
54
39
|
}
|
|
55
40
|
.tab-close:hover { background: #3a3a3a; color: #fff; }
|
|
56
41
|
#new-tab {
|
|
57
|
-
width: 36px;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
align-items: center;
|
|
61
|
-
justify-content: center;
|
|
62
|
-
font-size: 20px;
|
|
63
|
-
cursor: pointer;
|
|
64
|
-
color: #888;
|
|
65
|
-
-webkit-app-region: no-drag;
|
|
42
|
+
width: 36px; height: 36px; display: flex; align-items: center;
|
|
43
|
+
justify-content: center; font-size: 20px; cursor: pointer; color: #888;
|
|
44
|
+
-webkit-app-region: no-drag; flex: none;
|
|
66
45
|
}
|
|
67
46
|
#new-tab:hover { background: #2a2a2a; color: #fff; }
|
|
68
|
-
.tab-id { color: #555; font-size: 10px; margin-right: 4px; }
|
|
47
|
+
.tab-id { color: #555; font-size: 10px; margin-right: 4px; flex: none; }
|
|
48
|
+
|
|
49
|
+
/* ---- 第二行:工具栏(地址栏 + 导航按钮)---- */
|
|
50
|
+
#toolbar {
|
|
51
|
+
display: flex; align-items: center; gap: 6px;
|
|
52
|
+
height: 38px; padding: 0 8px; flex: none;
|
|
53
|
+
background: #202021; border-bottom: 1px solid #333;
|
|
54
|
+
}
|
|
55
|
+
.nav-btn {
|
|
56
|
+
width: 30px; height: 26px; display: flex; align-items: center;
|
|
57
|
+
justify-content: center; border: none; border-radius: 4px;
|
|
58
|
+
background: transparent; color: #bbb; font-size: 14px; cursor: pointer;
|
|
59
|
+
-webkit-app-region: no-drag; flex: none; padding: 0;
|
|
60
|
+
font-family: inherit;
|
|
61
|
+
}
|
|
62
|
+
.nav-btn:hover:not(:disabled) { background: #3a3a3a; color: #fff; }
|
|
63
|
+
.nav-btn:disabled { color: #4a4a4a; cursor: default; }
|
|
64
|
+
#addr {
|
|
65
|
+
flex: 1; min-width: 0; height: 26px;
|
|
66
|
+
background: #3c3c3c; border: 1px solid #3c3c3c; border-radius: 4px;
|
|
67
|
+
color: #e8e8e8; padding: 0 10px; font-size: 12px; font-family: inherit;
|
|
68
|
+
-webkit-app-region: no-drag;
|
|
69
|
+
}
|
|
70
|
+
#addr:focus { outline: none; border-color: #007acc; background: #2d2d2d; }
|
|
71
|
+
#status {
|
|
72
|
+
font-size: 11px; color: #777; flex: none; max-width: 260px;
|
|
73
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
74
|
+
}
|
|
75
|
+
#status.busy { color: #e0a33e; }
|
|
69
76
|
</style>
|
|
70
77
|
</head>
|
|
71
78
|
<body>
|
|
72
|
-
<div id="tabs"
|
|
73
|
-
<div id="
|
|
79
|
+
<div id="tabs-row">
|
|
80
|
+
<div id="tabs"></div>
|
|
81
|
+
<div id="new-tab" title="新建标签页">+</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div id="toolbar">
|
|
85
|
+
<button class="nav-btn" id="back" title="后退" disabled>←</button>
|
|
86
|
+
<button class="nav-btn" id="forward" title="前进" disabled>→</button>
|
|
87
|
+
<button class="nav-btn" id="reload" title="刷新">↻</button>
|
|
88
|
+
<button class="nav-btn" id="stop" title="停止加载" disabled>×</button>
|
|
89
|
+
<input id="addr" type="text" placeholder="输入网址,回车打开" spellcheck="false" autocomplete="off">
|
|
90
|
+
<span id="status"></span>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
74
93
|
<script>
|
|
75
94
|
const { ipcRenderer } = require('electron');
|
|
76
95
|
|
|
96
|
+
const $ = (id) => document.getElementById(id);
|
|
97
|
+
const tabsEl = $('tabs'), addrEl = $('addr'), statusEl = $('status');
|
|
98
|
+
const backBtn = $('back'), fwdBtn = $('forward'), reloadBtn = $('reload'), stopBtn = $('stop');
|
|
99
|
+
|
|
100
|
+
// ---- tab 条(原有逻辑,保持可用)----
|
|
101
|
+
// 主进程通过 window.__onNavState 推送地址栏状态(push 模式)。
|
|
102
|
+
// 也暴露 window.__applyNavState 供本地自取(nav:state invoke 的回落)。
|
|
103
|
+
window.__onNavState = function (s) { applyNavState(s); };
|
|
104
|
+
function applyNavState(s) {
|
|
105
|
+
activeTab = s.tab;
|
|
106
|
+
// 地址栏只在不是用户正在输入时才更新,否则会打断打字
|
|
107
|
+
if (document.activeElement !== addrEl) addrEl.value = s.url || '';
|
|
108
|
+
backBtn.disabled = !s.canBack;
|
|
109
|
+
fwdBtn.disabled = !s.canForward;
|
|
110
|
+
reloadBtn.style.display = s.loading ? 'none' : '';
|
|
111
|
+
stopBtn.style.display = s.loading ? '' : 'none';
|
|
112
|
+
stopBtn.disabled = !s.loading;
|
|
113
|
+
statusEl.textContent = s.status || '';
|
|
114
|
+
statusEl.className = s.loading ? 'busy' : '';
|
|
115
|
+
addrEl.style.borderColor = s.loading ? '#e0a33e' : '';
|
|
116
|
+
}
|
|
117
|
+
|
|
77
118
|
async function renderTabs() {
|
|
78
119
|
try {
|
|
79
120
|
const tabs = await ipcRenderer.invoke('tab:list');
|
|
80
|
-
|
|
81
|
-
container.innerHTML = '';
|
|
121
|
+
tabsEl.innerHTML = '';
|
|
82
122
|
for (const tab of tabs) {
|
|
83
123
|
const el = document.createElement('div');
|
|
84
124
|
el.className = 'tab' + (tab.active ? ' active' : '');
|
|
85
125
|
const title = (tab.title || tab.url || 'New Tab').slice(0, 30);
|
|
86
126
|
el.innerHTML = '<span class="tab-id">[' + tab.id + ']</span>' +
|
|
87
|
-
'<span class="tab-title">' + title + '</span>' +
|
|
127
|
+
'<span class="tab-title">' + escapeHtml(title) + '</span>' +
|
|
88
128
|
'<span class="tab-close" data-id="' + tab.id + '">×</span>';
|
|
89
|
-
el.addEventListener('click', function(e) {
|
|
129
|
+
el.addEventListener('click', function (e) {
|
|
90
130
|
if (e.target.classList.contains('tab-close')) {
|
|
91
131
|
ipcRenderer.send('tab:close', parseInt(e.target.dataset.id));
|
|
92
132
|
} else {
|
|
93
133
|
ipcRenderer.send('tab:activate', tab.id);
|
|
94
134
|
}
|
|
95
135
|
});
|
|
96
|
-
|
|
136
|
+
tabsEl.appendChild(el);
|
|
97
137
|
}
|
|
98
|
-
} catch(e) {
|
|
99
|
-
|
|
100
|
-
|
|
138
|
+
} catch (e) { /* ipcRenderer 尚未就绪 */ }
|
|
139
|
+
}
|
|
140
|
+
function escapeHtml(s) {
|
|
141
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
101
142
|
}
|
|
102
143
|
|
|
103
|
-
//
|
|
104
|
-
|
|
144
|
+
// ---- 地址栏 + 导航按钮 ----
|
|
145
|
+
// 所有交互都走 ipc 到主进程,由主进程操作对应 tab 的 webContents。
|
|
146
|
+
// 这里不直接碰页面:地址栏必须能反映**真实**状态(包括 agent 造成的导航),
|
|
147
|
+
// 所以显示数据全部由主进程推送,本地不缓存。
|
|
148
|
+
let activeTab = null;
|
|
105
149
|
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
ipcRenderer.on('nav:state', (_e, s) => applyNavState(s));
|
|
151
|
+
|
|
152
|
+
backBtn.addEventListener('click', () => ipcRenderer.send('nav:back'));
|
|
153
|
+
fwdBtn.addEventListener('click', () => ipcRenderer.send('nav:forward'));
|
|
154
|
+
reloadBtn.addEventListener('click', () => ipcRenderer.send('nav:reload'));
|
|
155
|
+
stopBtn.addEventListener('click', () => ipcRenderer.send('nav:stop'));
|
|
156
|
+
|
|
157
|
+
addrEl.addEventListener('keydown', (e) => {
|
|
158
|
+
if (e.key !== 'Enter') return;
|
|
159
|
+
const v = addrEl.value.trim();
|
|
160
|
+
if (!v) return;
|
|
161
|
+
addrEl.blur();
|
|
162
|
+
ipcRenderer.send('nav:go', v);
|
|
108
163
|
});
|
|
164
|
+
// 聚焦时全选,方便直接输入覆盖(浏览器惯例)
|
|
165
|
+
addrEl.addEventListener('focus', () => addrEl.select());
|
|
166
|
+
|
|
167
|
+
$('new-tab').addEventListener('click', () => ipcRenderer.send('tab:new'));
|
|
109
168
|
|
|
110
|
-
//
|
|
169
|
+
// 初始拉一次状态,然后 tab 条照旧定时刷新
|
|
170
|
+
ipcRenderer.invoke('nav:state').then((s) => { if (s) applyNavState(s); });
|
|
111
171
|
renderTabs();
|
|
112
172
|
setInterval(renderTabs, 1500);
|
|
113
173
|
</script>
|
|
114
174
|
</body>
|
|
115
|
-
</html>
|
|
175
|
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/shared/act_params.cjs — ui.act 的参数归一(纯函数)
|
|
2
|
+
//
|
|
3
|
+
// 坑:WS 层的 `ui.act` 只认 `params:{...}` 嵌套形状。把 `text` / `html` /
|
|
4
|
+
// `from_x` 放在顶层时它们**被静默丢掉**,动作照常执行但输入为空,回执是
|
|
5
|
+
// "type called with empty text" —— 调用方会以为自己传了文字,只是没生效。
|
|
6
|
+
// 这不是猜测:本轮实测里,同一个 act 调用写成 `{action, target, text}` 就失败,
|
|
7
|
+
// 写成 `{action, target, params:{text}}` 就成功。
|
|
8
|
+
//
|
|
9
|
+
// 现在两种形状都接受:嵌套优先,顶层未嵌套的已知动作参数被提升,并在回执里
|
|
10
|
+
// 用 params_hoisted 说明"这些是从顶层挪进来的",调用方不必靠猜。
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// 动作级参数(与 mcp_tools.browse_act 的 properties 同源)。tab/action/target
|
|
15
|
+
// 是 RPC 层自己的字段,不参与提升。
|
|
16
|
+
const ACTION_KEYS = [
|
|
17
|
+
'text', 'html', 'value', 'offset', 'limit', 'url', 'keep_tab', 'file', 'files',
|
|
18
|
+
'timeout_ms', 'to_target', 'from_anchor', 'to_anchor', 'hold', 'button', 'key', 'keys',
|
|
19
|
+
'dx', 'dy', 'to_x', 'to_y', 'from_x', 'from_y', 'delta_x', 'delta_y', 'steps',
|
|
20
|
+
'from', 'to', 'path',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} top WS 收到的完整 params
|
|
27
|
+
* @returns {{params: object, hoisted: string[]}}
|
|
28
|
+
*/
|
|
29
|
+
function hoistActParams(top) {
|
|
30
|
+
const src = top && typeof top === 'object' ? top : {};
|
|
31
|
+
const nested = src.params && typeof src.params === 'object' ? src.params : {};
|
|
32
|
+
const params = { ...nested };
|
|
33
|
+
const hoisted = [];
|
|
34
|
+
for (const k of ACTION_KEYS) {
|
|
35
|
+
if (src[k] !== undefined && params[k] === undefined) {
|
|
36
|
+
params[k] = src[k];
|
|
37
|
+
hoisted.push(k);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// 坐标快捷键:drag 的 from_x/from_y 与 to_x/to_y 在内部用 {from:{x,y}} 表达。
|
|
41
|
+
// 只在一个都没给、且两个分量都齐时才合成,避免半截坐标变成 (x, 0)。
|
|
42
|
+
if (params.from == null && num(params.from_x) && num(params.from_y)) {
|
|
43
|
+
params.from = { x: params.from_x, y: params.from_y };
|
|
44
|
+
if (!hoisted.includes('from')) hoisted.push('from(x,y)');
|
|
45
|
+
}
|
|
46
|
+
if (params.to == null && num(params.to_x) && num(params.to_y)) {
|
|
47
|
+
params.to = { x: params.to_x, y: params.to_y };
|
|
48
|
+
if (!hoisted.includes('to')) hoisted.push('to(x,y)');
|
|
49
|
+
}
|
|
50
|
+
return { params, hoisted };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { hoistActParams, ACTION_KEYS };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// 登录墙 / 登录态探测。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它(来自一次真实事故,不是假想):
|
|
4
|
+
// 2026-10-17 往 CSDN 发文章,会话早已失效,editor.csdn.net/md/ 被 302 到
|
|
5
|
+
// passport.csdn.net/login?code=applets。此时 ui.get_tree / ui.evaluate / ui.act
|
|
6
|
+
// 全部照常返回、毫无异常,操作在这个状态下继续了很久,最后失败被归因到
|
|
7
|
+
// bizapi 的 401 上 —— 而那只是会话失效的下游表现。排查顺序从最基础的
|
|
8
|
+
// "我还登着吗"就已经偏了。
|
|
9
|
+
//
|
|
10
|
+
// 更糟的是 context.session.logged_in 当时**主动说了谎**。它的 cookie 判定是:
|
|
11
|
+
// document.cookie.indexOf("token") >= 0
|
|
12
|
+
// CSDN 未登录时 cookie 串里照样有 csrfToken —— 子串命中 "token",于是判定
|
|
13
|
+
// logged_in = true。一个本该报警的字段,变成了掩护。
|
|
14
|
+
//
|
|
15
|
+
// 所以这里做三件事:
|
|
16
|
+
// 1. cookie 判定按**名字**匹配,并显式排除 csrf/xsrf 类(它们恰恰在未登录时也存在);
|
|
17
|
+
// 2. 提供显式的 auth_wall 信号 —— URL 命中登录域 / 页面上有密码输入框。它必须是
|
|
18
|
+
// 一眼可见的前置信号,而不是埋在 context 里的某个小字段;
|
|
19
|
+
// 3. logged_in 收紧:去掉"正文超过 1000 字就算登录"这种没有信息量的兜底
|
|
20
|
+
// (CSDN 登录页正文远超 1000 字,而误判方向是"说有登录"——代价最高的方向)。
|
|
21
|
+
//
|
|
22
|
+
// 诚实边界:这些都是启发式,权威信号只有 auth_wall.detected(URL / 密码框是硬事实)。
|
|
23
|
+
// logged_in 的语义是"看着像登录态",宁紧勿松 —— 误报未登录只会让调用方多验一次,
|
|
24
|
+
// 误报已登录会让它带着错误假设走完整条流程。
|
|
25
|
+
|
|
26
|
+
/** 主机名里的登录/鉴权服务标识。 */
|
|
27
|
+
const AUTH_HOST_RE = /(^|\.)(passport|login|signin|sso|auth|accounts|id)\./i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 路径段里的登录墙标识。按**段边界**匹配,不是全文 indexOf ——
|
|
31
|
+
* `/blog/login-tips` 这种讲登录的文章页不该被判成登录墙。
|
|
32
|
+
*/
|
|
33
|
+
const LOGIN_PATH_RE =
|
|
34
|
+
/(^|\/)(login|log-in|signin|sign-in|passport|sso|oauth|authorize|cas)(\/|$|[?#.])/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* cookie 名表示"已登录"的白名单(按完整名字/段匹配,不是任意子串)。
|
|
38
|
+
*
|
|
39
|
+
* 踩过的坑(都是真实数据,不是假想):
|
|
40
|
+
* - `uid` / `userid` **不能**进白名单:CSDN 未登录时的 `p_uid` 是埋点 partner id,
|
|
41
|
+
* 旧口径放它进去会让未登录态被判成已登录 —— 正是要防的那类误判;
|
|
42
|
+
* - `UserName` / `UserInfo` / `UserToken` 必须进:它们是 CSDN 的真鉴权项,
|
|
43
|
+
* 而 "token" 前后没有分隔符,靠 (^|_)token(_|$) 这种段匹配抓不到。
|
|
44
|
+
*/
|
|
45
|
+
const AUTH_COOKIE_NAME_RE =
|
|
46
|
+
/(^|_|-)(token|auth|session|sessionid|sid|jwt|login|loggedin|logged_in|access_token|refresh_token|username|userinfo|usertoken|nickname|uname)(_|$|-)/i;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* cookie 名排除项。csrf/xsrf token 是防跨站用的,未登录时照样存在 ——
|
|
50
|
+
* 把它算进登录证据,正是 CSDN 那次误判的直接原因。
|
|
51
|
+
*/
|
|
52
|
+
const CSRF_COOKIE_NAME_RE = /csrf|xsrf|_stm|stm_/i;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 这个 cookie 名能不能作为"已登录"的证据。
|
|
56
|
+
* @param {string} name cookie 名(不含值)
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function isAuthCookieName(name) {
|
|
60
|
+
const n = String(name || '').trim().toLowerCase();
|
|
61
|
+
if (!n) return false;
|
|
62
|
+
if (CSRF_COOKIE_NAME_RE.test(n)) return false;
|
|
63
|
+
if (AUTH_COOKIE_NAME_RE.test(n)) return true;
|
|
64
|
+
// 一些站点用裸名,如 `TOKEN` / `SESSION` / `Auth` / `UserName`。整体匹配才算,
|
|
65
|
+
// 避免 `csrfToken` 这类复合名靠子串蒙混过关。
|
|
66
|
+
return /^(token|auth|session|sessionid|sid|jwt|username|userinfo|usertoken|nickname|uname|login|loggedin)$/.test(n);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 把 `document.cookie` 串拆成名字列表。只需要名字,不解析值
|
|
71
|
+
* (值可能含 base64 的 '=',按第一个 '=' 切就够了)。
|
|
72
|
+
* @param {string} cookieString
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
function parseCookieNames(cookieString) {
|
|
76
|
+
return String(cookieString || '')
|
|
77
|
+
.split(';')
|
|
78
|
+
.map((kv) => kv.split('=')[0].trim())
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 登录墙探测。命中任一硬事实即 detected:
|
|
84
|
+
* - URL 的 host 命中登录/鉴权服务域名(passport.csdn.net、sso.example.com…)
|
|
85
|
+
* - URL 的 path 命中登录墙路径段(/login、/signin、/passport、/oauth/authorize…)
|
|
86
|
+
* - 页面上存在可见的密码输入框
|
|
87
|
+
*
|
|
88
|
+
* 纯文本"登录"字样**不**作为独立触发条件:大量正常页面都含这两个字
|
|
89
|
+
* (登录引导按钮、文档标题…),拿它当证据会把 auth_wall 变成噪音,
|
|
90
|
+
* 而噪音信号比没有信号更糟 —— 调用方会学会忽略它。
|
|
91
|
+
*
|
|
92
|
+
* @param {{url?: string, hasPasswordInput?: boolean}} input
|
|
93
|
+
* @returns {{detected: boolean, reason: string|null, matched: string|null}}
|
|
94
|
+
*/
|
|
95
|
+
function detectAuthWall(input) {
|
|
96
|
+
const url = String((input && input.url) || '');
|
|
97
|
+
const hasPasswordInput = !!(input && input.hasPasswordInput);
|
|
98
|
+
|
|
99
|
+
let host = '';
|
|
100
|
+
let path = '';
|
|
101
|
+
try {
|
|
102
|
+
const u = new URL(url, 'http://relative.invalid');
|
|
103
|
+
host = u.hostname || '';
|
|
104
|
+
path = u.pathname || '';
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// URL 解析失败时退化成字符串匹配,总比静默不判好。
|
|
107
|
+
host = url;
|
|
108
|
+
path = url;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const hostMatch = host.match(AUTH_HOST_RE);
|
|
112
|
+
if (hostMatch) {
|
|
113
|
+
// 去掉首尾的点:matched 要能直接读("passport" 而不是 "passport.")。
|
|
114
|
+
return { detected: true, reason: 'auth_host', matched: hostMatch[0].replace(/^\.+|\.+$/g, '') };
|
|
115
|
+
}
|
|
116
|
+
const pathMatch = path.match(LOGIN_PATH_RE);
|
|
117
|
+
if (pathMatch) {
|
|
118
|
+
return { detected: true, reason: 'login_path', matched: pathMatch[0] };
|
|
119
|
+
}
|
|
120
|
+
if (hasPasswordInput) {
|
|
121
|
+
return { detected: true, reason: 'password_field', matched: null };
|
|
122
|
+
}
|
|
123
|
+
return { detected: false, reason: null, matched: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* "看着像登录态吗"——收紧过的启发式。
|
|
128
|
+
*
|
|
129
|
+
* 与旧实现的差别:
|
|
130
|
+
* - 删掉 `bodyText.length > 1000`(没有信息量,且误判方向是"说有登录");
|
|
131
|
+
* - cookie 按名字判,csrf 类显式排除;
|
|
132
|
+
* - auth_wall.detected 是硬否决项:已经在登录墙上,不可能同时是登录态。
|
|
133
|
+
*
|
|
134
|
+
* @param {{url?: string, hasPasswordInput?: boolean, hasAvatar?: boolean,
|
|
135
|
+
* cookieString?: string, cookieNames?: string[]}} input
|
|
136
|
+
* @returns {{logged_in: boolean, evidence: string[]}}
|
|
137
|
+
*/
|
|
138
|
+
function looksLoggedIn(input) {
|
|
139
|
+
const url = String((input && input.url) || '');
|
|
140
|
+
const wall = detectAuthWall({ url, hasPasswordInput: input && input.hasPasswordInput });
|
|
141
|
+
const evidence = [];
|
|
142
|
+
|
|
143
|
+
if (wall.detected) {
|
|
144
|
+
return { logged_in: false, evidence: ['auth_wall:' + wall.reason] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let cookieNames = (input && input.cookieNames) || parseCookieNames(input && input.cookieString);
|
|
148
|
+
const hasAuthCookie = cookieNames.some(isAuthCookieName);
|
|
149
|
+
if (hasAuthCookie) evidence.push('auth_cookie');
|
|
150
|
+
if (input && input.hasAvatar) evidence.push('avatar');
|
|
151
|
+
|
|
152
|
+
return { logged_in: hasAuthCookie || !!(input && input.hasAvatar), evidence };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
AUTH_HOST_RE,
|
|
157
|
+
LOGIN_PATH_RE,
|
|
158
|
+
isAuthCookieName,
|
|
159
|
+
parseCookieNames,
|
|
160
|
+
detectAuthWall,
|
|
161
|
+
looksLoggedIn,
|
|
162
|
+
};
|