@mrrisega/dsh-remote 0.6.11 → 0.6.12
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/dsh-setup.mjs +55 -15
- package/package.json +3 -3
- package/packages/dsh-remote-web/lib/client.js +137 -0
- package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
- package/packages/dsh-remote-web/lib/index.js +748 -18
- package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
- package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
- package/packages/dsh-remote-web/package.json +5 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1451 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1575 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
- package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
|
@@ -0,0 +1,2526 @@
|
|
|
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, viewport-fit=cover" />
|
|
6
|
+
<meta name="theme-color" content="#f6f8fa" />
|
|
7
|
+
<title>dsh-remote</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f6f8fa; --card: #ffffff; --border: #d0d7de;
|
|
11
|
+
--text: #1f2328; --muted: #57606a; --accent: #0969da;
|
|
12
|
+
--ok: #1a7f37; --warn: #9a6700; --err: #cf222e;
|
|
13
|
+
}
|
|
14
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
15
|
+
html, body { height: 100%; }
|
|
16
|
+
body {
|
|
17
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
18
|
+
background: var(--bg); color: var(--text); min-height: 100vh;
|
|
19
|
+
display: flex; flex-direction: column;
|
|
20
|
+
}
|
|
21
|
+
header {
|
|
22
|
+
padding: 12px 20px; display: flex; align-items: center; gap: 12px;
|
|
23
|
+
border-bottom: 1px solid var(--border); background: rgba(255,255,255,.85);
|
|
24
|
+
backdrop-filter: blur(8px); z-index: 10;
|
|
25
|
+
}
|
|
26
|
+
header .logo {
|
|
27
|
+
width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
|
|
28
|
+
background: linear-gradient(135deg, var(--accent), #6e40c9);
|
|
29
|
+
display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px;
|
|
30
|
+
color: #fff;
|
|
31
|
+
}
|
|
32
|
+
header h1 { font-size: 15px; font-weight: 600; }
|
|
33
|
+
header .sub { font-size: 11px; color: var(--muted); }
|
|
34
|
+
header .spacer { flex: 1; }
|
|
35
|
+
/* 顶部导航按钮统一风格:官网 / 升级方案 / 退出 同款胶囊按钮 */
|
|
36
|
+
header .nav-link, header .nav-btn, #btn-logout {
|
|
37
|
+
font-size: 12px; padding: 0 12px; height: 29px; line-height: 27px; border-radius: 999px; cursor: pointer;
|
|
38
|
+
border: 1px solid var(--border); background: var(--bg); color: var(--text);
|
|
39
|
+
text-decoration: none; white-space: nowrap; flex: none; width: auto; margin-top: 0;
|
|
40
|
+
box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center;
|
|
41
|
+
}
|
|
42
|
+
header .nav-btn.accent { border-color: var(--accent); color: var(--accent); font-weight: 700; }
|
|
43
|
+
header .nav-link:active, header .nav-btn:active, #btn-logout:active { opacity: .7; }
|
|
44
|
+
main { flex: 1; padding: 20px; max-width: 640px; width: 100%; margin: 0 auto; }
|
|
45
|
+
.card {
|
|
46
|
+
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
|
47
|
+
padding: 20px; margin-bottom: 16px;
|
|
48
|
+
}
|
|
49
|
+
.card h2 { font-size: 15px; font-weight: 600; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
|
|
50
|
+
.card h2 .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
|
51
|
+
.card h2 .dot.on { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
|
|
52
|
+
label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 6px; }
|
|
53
|
+
input {
|
|
54
|
+
width: 100%; padding: 12px 14px; border-radius: 8px; font-size: 15px;
|
|
55
|
+
background: var(--bg); border: 1px solid var(--border); color: var(--text); outline: none;
|
|
56
|
+
}
|
|
57
|
+
input:focus { border-color: var(--accent); }
|
|
58
|
+
button {
|
|
59
|
+
width: 100%; padding: 13px; border-radius: 8px; font-size: 15px; font-weight: 600;
|
|
60
|
+
background: var(--accent); color: #fff; border: none; cursor: pointer; margin-top: 14px;
|
|
61
|
+
}
|
|
62
|
+
button:disabled { opacity: .5; cursor: not-allowed; }
|
|
63
|
+
button.secondary { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
|
64
|
+
.msg { font-size: 13px; margin-top: 10px; min-height: 18px; }
|
|
65
|
+
.msg.err { color: var(--err); }
|
|
66
|
+
.msg.ok { color: var(--ok); }
|
|
67
|
+
.device {
|
|
68
|
+
display: flex; align-items: center; gap: 12px; padding: 14px;
|
|
69
|
+
border: 1px solid var(--border); border-radius: 10px; margin-bottom: 10px;
|
|
70
|
+
background: var(--bg); cursor: pointer;
|
|
71
|
+
}
|
|
72
|
+
.device:active { border-color: var(--accent); }
|
|
73
|
+
.device .icon { width: 40px; height: 40px; border-radius: 10px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 18px; background: rgba(47,129,247,.12); }
|
|
74
|
+
.device .name { font-size: 15px; font-weight: 600; }
|
|
75
|
+
.device .meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
76
|
+
.device .status { margin-left: auto; font-size: 12px; padding: 3px 10px; border-radius: 20px; }
|
|
77
|
+
.device .status.on { color: var(--ok); background: rgba(63,185,80,.12); }
|
|
78
|
+
/* 离线设备:整行置灰、不可进入,标注离线原因 */
|
|
79
|
+
.device.off { cursor: default; }
|
|
80
|
+
.device.off:active { border-color: var(--border); }
|
|
81
|
+
.device.off .icon { background: rgba(87,96,106,.16); }
|
|
82
|
+
.device.off .name { color: var(--muted); }
|
|
83
|
+
.list-hint {
|
|
84
|
+
margin: 2px 0 14px; padding: 10px 12px; font-size: 12.5px; color: var(--muted);
|
|
85
|
+
background: var(--bg); border: 1px dashed var(--border); border-radius: 10px; line-height: 1.75;
|
|
86
|
+
}
|
|
87
|
+
.empty { text-align: center; color: var(--muted); font-size: 13px; padding: 24px 0; }
|
|
88
|
+
/* ---------- 新用户安装引导(空设备状态):市场路径(条件)+ 命令路径(兜底)+ 收敛指引 ---------- */
|
|
89
|
+
.guide-path { border: 1px solid var(--line, #d0d7de); border-radius: 10px; padding: 12px 14px; margin: 10px 0; background: #f6f8fa; }
|
|
90
|
+
.guide-path-tag { display: inline-block; font-size: 12px; font-weight: 700; color: #1a7f37; background: #dafbe1; border-radius: 999px; padding: 1px 10px; margin-bottom: 8px; }
|
|
91
|
+
.guide-more { margin-top: 8px; font-size: 13px; }
|
|
92
|
+
.guide-more > summary { cursor: pointer; color: #0969da; }
|
|
93
|
+
.guide-more > ol { margin-top: 8px; }
|
|
94
|
+
.guide-wait { color: #57606a; font-size: 12.5px; }
|
|
95
|
+
.guide {
|
|
96
|
+
text-align: left; background: var(--bg); border: 1px solid var(--border);
|
|
97
|
+
border-radius: 10px; padding: 14px 16px; margin: 4px 0 10px;
|
|
98
|
+
font-size: 13px; color: var(--text); line-height: 1.8;
|
|
99
|
+
}
|
|
100
|
+
.guide .guide-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
|
101
|
+
.guide .guide-sub { color: var(--muted); font-size: 12.5px; margin-bottom: 8px; }
|
|
102
|
+
.guide ol { padding-left: 20px; margin: 0; }
|
|
103
|
+
.guide li { margin-bottom: 10px; }
|
|
104
|
+
.guide code {
|
|
105
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "PingFang SC", monospace;
|
|
106
|
+
font-size: 12px; background: rgba(9,105,218,.08); color: var(--accent);
|
|
107
|
+
padding: 1px 5px; border-radius: 4px; word-break: break-all;
|
|
108
|
+
}
|
|
109
|
+
.guide-cmd {
|
|
110
|
+
display: flex; align-items: center; gap: 8px; margin-top: 6px;
|
|
111
|
+
background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px;
|
|
112
|
+
}
|
|
113
|
+
.guide-cmd code { flex: 1; min-width: 0; background: none; color: var(--text); padding: 0; }
|
|
114
|
+
.guide-copy {
|
|
115
|
+
flex-shrink: 0; width: auto; margin: 0; padding: 5px 10px; font-size: 12px;
|
|
116
|
+
background: transparent; border: 1px solid var(--border); color: var(--accent);
|
|
117
|
+
border-radius: 6px; cursor: pointer;
|
|
118
|
+
}
|
|
119
|
+
.guide-copy:active { background: rgba(9,105,218,.08); }
|
|
120
|
+
.hidden { display: none !important; }
|
|
121
|
+
.loading { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: middle; }
|
|
122
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
123
|
+
.toast {
|
|
124
|
+
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
125
|
+
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
|
126
|
+
padding: 10px 18px; font-size: 13px; z-index: 100; display: none; max-width: 90vw;
|
|
127
|
+
}
|
|
128
|
+
.toast.show { display: block; }
|
|
129
|
+
|
|
130
|
+
/* ---------- 认证卡片(浅色高对比,遵循主流登录体验) ---------- */
|
|
131
|
+
#view-login { padding: 12px 0; }
|
|
132
|
+
.auth-card {
|
|
133
|
+
background: #ffffff; border: 1px solid #d0d7de; border-radius: 12px;
|
|
134
|
+
padding: 24px; max-width: 420px; margin: 0 auto; color: #1f2328;
|
|
135
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.10);
|
|
136
|
+
}
|
|
137
|
+
.auth-card h2 { font-size: 17px; font-weight: 700; color: #1f2328; margin-bottom: 4px; }
|
|
138
|
+
.auth-tabs { display: flex; gap: 8px; margin: 16px 0; }
|
|
139
|
+
.auth-tab {
|
|
140
|
+
flex: 1; padding: 9px 0; text-align: center; border-radius: 8px; cursor: pointer;
|
|
141
|
+
font-size: 14px; font-weight: 600; color: #57606a; background: #f6f8fa; border: 1px solid #d0d7de;
|
|
142
|
+
user-select: none;
|
|
143
|
+
}
|
|
144
|
+
.auth-tab.active { color: #0969da; background: #ffffff; border-color: #0969da; }
|
|
145
|
+
.auth-field { margin-bottom: 13px; }
|
|
146
|
+
.auth-field > label { display: block; font-size: 13px; font-weight: 600; color: #1f2328; margin: 0 0 6px; }
|
|
147
|
+
.auth-input {
|
|
148
|
+
width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid #d0d7de;
|
|
149
|
+
background: #ffffff; color: #1f2328; font-size: 15px; outline: none; box-sizing: border-box;
|
|
150
|
+
}
|
|
151
|
+
.auth-input:focus { border-color: #0969da; box-shadow: 0 0 0 3px rgba(9,105,218,.15); }
|
|
152
|
+
.auth-btn {
|
|
153
|
+
width: 100%; padding: 11px; border-radius: 8px; border: none; background: #0969da;
|
|
154
|
+
color: #ffffff; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 4px;
|
|
155
|
+
}
|
|
156
|
+
.auth-btn:disabled { opacity: .6; cursor: not-allowed; }
|
|
157
|
+
.auth-btn.ghost { background: #f6f8fa; color: #1f2328; border: 1px solid #d0d7de; }
|
|
158
|
+
.auth-btn.danger { background: #ffffff; color: #cf222e; border: 1px solid #cf222e; }
|
|
159
|
+
.auth-captcha { display: flex; gap: 8px; align-items: stretch; }
|
|
160
|
+
.auth-captcha .auth-input { flex: 1; min-width: 0; }
|
|
161
|
+
.auth-captcha-img {
|
|
162
|
+
width: 118px; height: 44px; border-radius: 8px; border: 1px solid #d0d7de; cursor: pointer;
|
|
163
|
+
background: #f6f8fa; display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0;
|
|
164
|
+
}
|
|
165
|
+
.auth-captcha-img svg { display: block; width: 100%; height: 100%; }
|
|
166
|
+
.auth-sms { display: flex; gap: 8px; align-items: stretch; }
|
|
167
|
+
.auth-sms .auth-input { flex: 1; min-width: 0; }
|
|
168
|
+
.auth-send-btn {
|
|
169
|
+
flex: 0 0 auto; width: auto; margin-top: 0; padding: 0 16px;
|
|
170
|
+
border-radius: 8px; border: 1px solid #0969da;
|
|
171
|
+
background: #ffffff; color: #0969da; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap;
|
|
172
|
+
}
|
|
173
|
+
.auth-send-btn:disabled { color: #8c959f; border-color: #d0d7de; cursor: not-allowed; }
|
|
174
|
+
.auth-link { background: none; border: none; color: #0969da; font-size: 12.5px; cursor: pointer; padding: 0; }
|
|
175
|
+
.auth-link:hover { text-decoration: underline; }
|
|
176
|
+
.auth-hint { font-size: 14px; color: #57606a; margin-top: 6px; line-height: 1.7; }
|
|
177
|
+
.auth-msg { font-size: 14px; margin-top: 10px; min-height: 18px; }
|
|
178
|
+
/* Router 引导回跳提示条(会话过期/设备离线/设备不可用)。
|
|
179
|
+
⚠️ 刻意**不用 flex**:`.card` 在部分页面是 flex 容器,混用会把文案挤成一字一行
|
|
180
|
+
(实测 2026-09-19),这里用普通块级 + 绝对定位的关闭按钮,与 .card 的任何 display 都互不干扰。 */
|
|
181
|
+
.entry-hint { position: relative; font-size: 14px; line-height: 1.7; padding-right: 34px; text-align: left; }
|
|
182
|
+
.entry-hint.hidden { display: none; }
|
|
183
|
+
.entry-hint.warn { border-left: 3px solid var(--warn, #9a6700); }
|
|
184
|
+
.entry-hint-ico { margin-right: 6px; }
|
|
185
|
+
.entry-hint-msg { overflow-wrap: anywhere; }
|
|
186
|
+
.entry-hint-close { position: absolute; top: 6px; right: 8px; border: none; background: none; color: var(--muted, #6b7280); font-size: 16px; line-height: 1; cursor: pointer; padding: 4px 6px; }
|
|
187
|
+
.auth-msg.err { color: #cf222e; }
|
|
188
|
+
.auth-msg.ok { color: #1a7f37; }
|
|
189
|
+
.auth-user { display: flex; align-items: center; gap: 10px; margin: 12px 0 16px; }
|
|
190
|
+
.auth-user .avatar { width: 40px; height: 40px; border-radius: 50%; background: #0969da; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 17px; font-weight: 700; flex-shrink: 0; }
|
|
191
|
+
.auth-user .mail { font-size: 14px; font-weight: 600; color: #1f2328; word-break: break-all; }
|
|
192
|
+
.auth-user .plan { font-size: 12px; color: #57606a; margin-top: 2px; }
|
|
193
|
+
|
|
194
|
+
/* ---- 大号行动按钮(升级方案 / 邀请链接) ---- */
|
|
195
|
+
.usage-card { font-size: 14px; color: var(--text); line-height: 1.9; }
|
|
196
|
+
.usage-card code { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-size: 13px; }
|
|
197
|
+
/* ---------- 弹层(支付二维码 / 挽留) ---------- */
|
|
198
|
+
.modal-overlay { position: fixed; inset: 0; z-index: 900; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; padding: 20px; }
|
|
199
|
+
.modal-card { background: #ffffff; border: 1px solid var(--border); border-radius: 14px; padding: 22px; width: 100%; max-width: 340px; position: relative; box-shadow: 0 12px 40px rgba(0,0,0,.25); text-align: center; }
|
|
200
|
+
.modal-card h3 { font-size: 16px; font-weight: 700; color: var(--text); margin-bottom: 14px; }
|
|
201
|
+
.modal-close { position: absolute; top: 8px; right: 12px; width: 32px; height: 32px; margin: 0; padding: 0; background: transparent; border: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; border-radius: 50%; }
|
|
202
|
+
.modal-close:active { background: var(--bg); }
|
|
203
|
+
|
|
204
|
+
/* ---------- E2EE(端到端加密)Phase-3:状态徽标 / 能力标记 / 解锁层 ----------
|
|
205
|
+
* 视觉风格与全页一致(浅色卡片 + 胶囊徽标)。徽标与解锁层文案见
|
|
206
|
+
* docs/e2ee-protocol.md §2.3(信任文案)。密钥仅驻内存,见 JS 端注释。 */
|
|
207
|
+
#dsh-e2ee-badge {
|
|
208
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
209
|
+
font-size: 11px; padding: 2px 10px; height: 24px; border-radius: 999px;
|
|
210
|
+
white-space: nowrap; border: 1px solid var(--border); background: var(--bg);
|
|
211
|
+
color: var(--muted); margin-left: 4px; flex: none;
|
|
212
|
+
}
|
|
213
|
+
#dsh-e2ee-badge.ok { color: var(--ok); border-color: rgba(26,127,55,.45); background: rgba(26,127,55,.08); }
|
|
214
|
+
#dsh-e2ee-badge.warn { color: var(--warn); border-color: rgba(154,103,0,.4); background: rgba(154,103,0,.07); }
|
|
215
|
+
#dsh-e2ee-badge.err { color: var(--err); border-color: rgba(207,34,46,.4); background: rgba(207,34,46,.06); }
|
|
216
|
+
.e2ee-chip {
|
|
217
|
+
display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 999px;
|
|
218
|
+
color: var(--ok); background: rgba(26,127,55,.1); border: 1px solid rgba(26,127,55,.35);
|
|
219
|
+
margin-left: 6px; vertical-align: 1px; white-space: nowrap;
|
|
220
|
+
}
|
|
221
|
+
.device .e2ee-cap { margin-left: 8px; font-size: 15px; }
|
|
222
|
+
#unlock-modal .modal-card { max-width: 400px; text-align: left; }
|
|
223
|
+
#unlock-modal h3 { margin-bottom: 6px; }
|
|
224
|
+
.e2ee-unlock-who { font-size: 13px; color: var(--text); margin-bottom: 4px; }
|
|
225
|
+
.e2ee-unlock-who b { font-weight: 600; }
|
|
226
|
+
.e2ee-unlock-copy {
|
|
227
|
+
font-size: 12.5px; color: var(--muted); line-height: 1.85; text-align: left;
|
|
228
|
+
background: var(--bg); border: 1px dashed var(--border); border-radius: 10px;
|
|
229
|
+
padding: 10px 12px; margin: 8px 0 4px;
|
|
230
|
+
}
|
|
231
|
+
.e2ee-unlock-copy b { color: var(--text); }
|
|
232
|
+
.e2ee-unlock-msg { font-size: 13px; margin-top: 8px; min-height: 16px; }
|
|
233
|
+
.e2ee-unlock-msg.err { color: var(--err); }
|
|
234
|
+
.e2ee-unlock-msg.ok { color: var(--ok); }
|
|
235
|
+
#unlock-modal .auth-btn { margin-top: 12px; }
|
|
236
|
+
.e2ee-unlock-row { display: flex; gap: 10px; margin-top: 12px; }
|
|
237
|
+
.e2ee-unlock-row .auth-btn { flex: 1; margin: 0; }
|
|
238
|
+
.e2ee-unlock-note { font-size: 11.5px; color: var(--muted); margin-top: 10px; line-height: 1.7; }
|
|
239
|
+
.e2ee-statusline {
|
|
240
|
+
margin: 2px 0 10px; padding: 8px 12px; font-size: 12px; line-height: 1.7;
|
|
241
|
+
background: var(--bg); border: 1px solid var(--border); border-left: 3px solid var(--ok);
|
|
242
|
+
border-radius: 8px; color: var(--text);
|
|
243
|
+
}
|
|
244
|
+
.e2ee-statusline.warn { border-left-color: var(--warn); }
|
|
245
|
+
|
|
246
|
+
/* ==================================================================
|
|
247
|
+
移动端适配层(2026-09-17)
|
|
248
|
+
------------------------------------------------------------------
|
|
249
|
+
为什么补这一层:此前整份文件 **没有任何 @media 规则**,也没有处理
|
|
250
|
+
safe-area —— 但 <meta viewport> 声明了 viewport-fit=cover,等于「宣称要
|
|
251
|
+
铺满刘海屏」却不为刘海/Home 条留边,内容会被遮住。
|
|
252
|
+
实测在 375px 宽(iPhone SE/12 mini 等)上还会出现:
|
|
253
|
+
· header 一行塞了 logo+标题+副标题+退出+E2EE 徽标+官网+升级按钮,
|
|
254
|
+
flex 不换行且子项不收缩 → 右侧按钮被挤出屏幕;
|
|
255
|
+
· 设备名(如 gaoangdeMacBook-Pro.local)不截断,把「在线」状态胶囊顶出可视区。
|
|
256
|
+
本层只做「小屏与安全区」相关修正,桌面观感保持不变。
|
|
257
|
+
================================================================== */
|
|
258
|
+
|
|
259
|
+
/* ---- 安全区(所有尺寸生效:只要用了 viewport-fit=cover 就该留边) ---- */
|
|
260
|
+
body {
|
|
261
|
+
padding-left: env(safe-area-inset-left, 0px);
|
|
262
|
+
padding-right: env(safe-area-inset-right, 0px);
|
|
263
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
264
|
+
/* 移动端点按不要出现灰色高亮块,改用 :active 反馈 */
|
|
265
|
+
-webkit-tap-highlight-color: transparent;
|
|
266
|
+
/* 下拉回弹不带动整页(避免误触刷新) */
|
|
267
|
+
overscroll-behavior-y: contain;
|
|
268
|
+
}
|
|
269
|
+
header { padding-top: max(12px, env(safe-area-inset-top, 0px)); }
|
|
270
|
+
|
|
271
|
+
/* 弹性子项默认 min-width:auto,长文本会把兄弟挤出去;统一允许收缩 */
|
|
272
|
+
header > div, .card h2, .device > div { min-width: 0; }
|
|
273
|
+
|
|
274
|
+
/* ---- 小屏(≤480px:绝大多数手机竖屏) ---- */
|
|
275
|
+
@media (max-width: 480px) {
|
|
276
|
+
main { padding: 12px; }
|
|
277
|
+
.card { padding: 14px; border-radius: 10px; margin-bottom: 12px; }
|
|
278
|
+
|
|
279
|
+
/* header:允许换行成两行,标题截断,按钮不再被挤出屏幕 */
|
|
280
|
+
header { padding: 10px 12px; gap: 8px; flex-wrap: wrap; row-gap: 8px; }
|
|
281
|
+
header .logo { width: 28px; height: 28px; font-size: 12px; border-radius: 7px; }
|
|
282
|
+
header h1 { font-size: 14px; }
|
|
283
|
+
header .sub { font-size: 10.5px; }
|
|
284
|
+
header > div { overflow: hidden; }
|
|
285
|
+
header h1, header .sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
286
|
+
/* 导航胶囊:小屏收紧内边距,但保持 ≥30px 的可点高度 */
|
|
287
|
+
header .nav-link, header .nav-btn, #btn-logout {
|
|
288
|
+
font-size: 11.5px; padding: 0 10px; height: 30px; line-height: 28px;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* 设备行:名称截断,状态胶囊固定不被顶走 */
|
|
292
|
+
.device { padding: 12px; gap: 10px; margin-bottom: 8px; }
|
|
293
|
+
.device .icon { width: 36px; height: 36px; font-size: 16px; border-radius: 9px; }
|
|
294
|
+
.device .name { font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
295
|
+
.device .meta {
|
|
296
|
+
font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
297
|
+
}
|
|
298
|
+
.device .status { flex: none; font-size: 11.5px; padding: 3px 9px; }
|
|
299
|
+
|
|
300
|
+
/* 触摸目标:主要操作按钮抬高到 ≥44px(可点性下限) */
|
|
301
|
+
button { padding: 14px; }
|
|
302
|
+
|
|
303
|
+
/* 代码块/长 URL 不撑破布局 */
|
|
304
|
+
code, .list-hint, .guide { overflow-wrap: anywhere; }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* ---- 极窄屏(≤360px) ---- */
|
|
308
|
+
@media (max-width: 360px) {
|
|
309
|
+
main { padding: 10px; }
|
|
310
|
+
.device .icon { width: 32px; height: 32px; font-size: 15px; }
|
|
311
|
+
.card { padding: 12px; }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* ---- 横屏矮屏:压缩纵向留白,避免首屏只剩 header ---- */
|
|
315
|
+
@media (max-height: 480px) and (orientation: landscape) {
|
|
316
|
+
main { padding: 10px 14px; }
|
|
317
|
+
.card { padding: 12px 14px; margin-bottom: 10px; }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ---- 极窄屏:官网链接次要,让位给「升级方案」 ---- */
|
|
321
|
+
@media (max-width: 360px) {
|
|
322
|
+
header .nav-link { display: none; }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
</style>
|
|
326
|
+
<script>
|
|
327
|
+
/* 百度统计(仅官方 SaaS 域名生效):
|
|
328
|
+
* 本页面同时被 SaaS 与自建部署使用;自建部署(其他域名)不加载、不采集任何数据。 */
|
|
329
|
+
var _hmt = _hmt || [];
|
|
330
|
+
(function() {
|
|
331
|
+
if (location.hostname !== "n.risegao.cn") return; // 非官方 SaaS 域名:跳过采集
|
|
332
|
+
var hm = document.createElement("script");
|
|
333
|
+
hm.src = "https://hm.baidu.com/hm.js?9ec1d21cfebe4404b427e8fb44289f42";
|
|
334
|
+
var s = document.getElementsByTagName("script")[0];
|
|
335
|
+
s.parentNode.insertBefore(hm, s);
|
|
336
|
+
})();
|
|
337
|
+
</script>
|
|
338
|
+
</head>
|
|
339
|
+
<body>
|
|
340
|
+
<header>
|
|
341
|
+
<div class="logo">DSH</div>
|
|
342
|
+
<div>
|
|
343
|
+
<h1>dsh-remote</h1>
|
|
344
|
+
<div class="sub" id="conn-status">未连接</div>
|
|
345
|
+
</div>
|
|
346
|
+
<button id="btn-logout" class="hidden">退出</button>
|
|
347
|
+
<span id="dsh-e2ee-badge" class="hidden" title="端到端加密状态"></span>
|
|
348
|
+
<div class="spacer"></div>
|
|
349
|
+
<a class="nav-link" href="https://n.risegao.cn:8085/" target="_blank" rel="noopener">官网</a>
|
|
350
|
+
</header>
|
|
351
|
+
|
|
352
|
+
<main id="view-main">
|
|
353
|
+
<!-- Router 引导回跳提示(/app/?reason=expired|offline|forbidden|unknown_device,2026-09-19) -->
|
|
354
|
+
<div class="card entry-hint hidden" id="entry-hint" role="status">
|
|
355
|
+
<span class="entry-hint-ico" id="entry-hint-ico">ℹ️</span>
|
|
356
|
+
<span class="entry-hint-msg" id="entry-hint-msg"></span>
|
|
357
|
+
<button type="button" class="entry-hint-close" id="entry-hint-close" aria-label="关闭提示">✕</button>
|
|
358
|
+
</div>
|
|
359
|
+
<!-- 首屏读取态:会话判定完成前不渲染登录或设备界面,消除闪烁 -->
|
|
360
|
+
<div id="view-boot">
|
|
361
|
+
<div class="card" style="text-align:center;">
|
|
362
|
+
<div class="empty" id="boot-status-text"><span class="loading"></span> 正在读取登录状态…</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<!-- 登录/注册视图 -->
|
|
367
|
+
<div id="view-login" class="hidden">
|
|
368
|
+
<div class="auth-card">
|
|
369
|
+
<!-- 已登录态 -->
|
|
370
|
+
<div id="auth-logged-in" class="hidden">
|
|
371
|
+
<h2>账号</h2>
|
|
372
|
+
<div class="auth-user">
|
|
373
|
+
<div class="avatar" id="auth-avatar">D</div>
|
|
374
|
+
<div>
|
|
375
|
+
<div class="mail" id="auth-email">-</div>
|
|
376
|
+
<div class="plan" id="auth-plan">-</div>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
<button class="auth-btn danger" id="btn-logout-inline" style="margin-top:8px;">退出登录</button>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<!-- 未登录态:登录/注册 tabs -->
|
|
383
|
+
<div id="auth-logged-out">
|
|
384
|
+
<h2 id="auth-login-title">远程控制</h2>
|
|
385
|
+
<div class="auth-hint" id="auth-hint-login">登录后可远程控制电脑上的 dsh web,体验与本地一致。</div>
|
|
386
|
+
<div class="auth-tabs">
|
|
387
|
+
<div class="auth-tab active" id="tab-login">登录</div>
|
|
388
|
+
<div class="auth-tab" id="tab-register">注册</div>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
<!-- 登录表单 -->
|
|
392
|
+
<div id="auth-login-form">
|
|
393
|
+
<div class="auth-field"><label>手机号</label><input class="auth-input" id="login-phone" type="tel" placeholder="11 位手机号" autocomplete="tel" inputmode="tel" /></div>
|
|
394
|
+
<!-- 密码登录(默认) -->
|
|
395
|
+
<div id="login-mode-pass">
|
|
396
|
+
<div class="auth-field"><label>密码</label><input class="auth-input" id="login-pass" type="password" placeholder="密码" autocomplete="current-password" /></div>
|
|
397
|
+
<!-- 渐进式图形验证码:默认隐藏,仅当服务端要求(连续失败/首次登录被拒)时展示 -->
|
|
398
|
+
<div class="auth-field hidden" id="login-captcha-row">
|
|
399
|
+
<label>验证码</label>
|
|
400
|
+
<div class="auth-captcha"><input class="auth-input" id="login-captcha" type="text" placeholder="图中数字" autocomplete="off" inputmode="numeric" maxlength="6" /><div class="auth-captcha-img" id="login-captcha-img" title="看不清?点击刷新"></div></div>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
<!-- 短信验证码登录(次级,不显眼) -->
|
|
404
|
+
<div id="login-mode-sms" class="hidden">
|
|
405
|
+
<div class="auth-field"><label>短信验证码</label>
|
|
406
|
+
<div class="auth-sms"><input class="auth-input" id="login-sms-code" type="text" placeholder="6 位验证码" autocomplete="off" inputmode="numeric" maxlength="6" /><button class="auth-send-btn" id="btn-send-login-sms" type="button">获取验证码</button></div>
|
|
407
|
+
</div>
|
|
408
|
+
<div class="auth-field hidden" id="login-sms-captcha-row">
|
|
409
|
+
<div class="auth-captcha"><input class="auth-input" id="login-sms-captcha" type="text" placeholder="图中数字" autocomplete="off" inputmode="numeric" maxlength="6" /><div class="auth-captcha-img" id="login-sms-captcha-img" title="看不清?点击刷新"></div></div>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
<!-- 开源自部署:访问密钥登录(本地认证,免账号体系) -->
|
|
413
|
+
<div id="login-mode-local" class="hidden">
|
|
414
|
+
<div class="auth-field"><label>访问密钥</label><input class="auth-input" id="login-local-key" type="password" placeholder="自建服务的访问密钥" autocomplete="off" /></div>
|
|
415
|
+
<button class="auth-btn" id="btn-local-login">密钥登录</button>
|
|
416
|
+
<div class="auth-hint">自建服务模式:在你自己的服务器部署后,在服务器配置中生成访问密钥填入即可。</div>
|
|
417
|
+
</div>
|
|
418
|
+
<button class="auth-btn" id="btn-login">登 录</button>
|
|
419
|
+
<div style="text-align:center;margin-top:8px;"><button class="auth-link" id="btn-toggle-login-sms" type="button">验证码登录</button> · <button class="auth-link" id="btn-toggle-login-local" type="button">自建服务密钥</button></div>
|
|
420
|
+
<!-- 忘记密码(短信重置):仅 SaaS 账号体系(自建模式在 applyDeploymentMode 隐藏) -->
|
|
421
|
+
<div style="text-align:center;margin-top:4px;"><button class="auth-link" id="btn-forgot-pass" type="button">忘记密码?</button></div>
|
|
422
|
+
<div class="auth-msg" id="login-msg"></div>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
<!-- 注册表单 -->
|
|
426
|
+
<div id="auth-register-form" class="hidden">
|
|
427
|
+
<div class="auth-field"><label>手机号</label><input class="auth-input" id="reg-phone" type="tel" placeholder="11 位手机号" autocomplete="tel" inputmode="tel" /></div>
|
|
428
|
+
<div class="auth-field"><label>短信验证码</label>
|
|
429
|
+
<div class="auth-sms"><input class="auth-input" id="reg-sms-code" type="text" placeholder="6 位验证码" autocomplete="off" inputmode="numeric" maxlength="6" /><button class="auth-send-btn" id="btn-send-reg-sms" type="button">获取验证码</button></div>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="auth-field hidden" id="reg-sms-captcha-row">
|
|
432
|
+
<div class="auth-captcha"><input class="auth-input" id="reg-sms-captcha" type="text" placeholder="图中数字" autocomplete="off" inputmode="numeric" maxlength="6" /><div class="auth-captcha-img" id="reg-sms-captcha-img" title="看不清?点击刷新"></div></div>
|
|
433
|
+
</div>
|
|
434
|
+
<div class="auth-field"><label>密码</label><input class="auth-input" id="reg-pass" type="password" placeholder="至少 8 位" autocomplete="new-password" /></div>
|
|
435
|
+
<div class="auth-field"><label>确认密码</label><input class="auth-input" id="reg-pass2" type="password" placeholder="再次输入密码" autocomplete="new-password" /></div>
|
|
436
|
+
<button class="auth-btn" id="btn-register">注 册</button>
|
|
437
|
+
<div class="auth-msg" id="reg-msg"></div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
<!-- 忘记密码 / 短信重置(仅 SaaS 账号体系):手机号 + 图形验证码 + 短信验证码 + 新密码。
|
|
442
|
+
提交 POST {apiBase}/api/password/reset(body {phone,sms_code,new_password} → 200 {ok:true});
|
|
443
|
+
成功后本地 token/cookie/threads 全清,回登录页用新密码登录。 -->
|
|
444
|
+
<div id="auth-reset-form" class="hidden">
|
|
445
|
+
<h2>重置密码</h2>
|
|
446
|
+
<div class="auth-field"><label>手机号</label><input class="auth-input" id="reset-phone" type="tel" placeholder="11 位手机号" autocomplete="tel" inputmode="tel" /></div>
|
|
447
|
+
<div class="auth-field" id="reset-captcha-row">
|
|
448
|
+
<label>图形验证码</label>
|
|
449
|
+
<div class="auth-captcha"><input class="auth-input" id="reset-captcha" type="text" placeholder="图中数字" autocomplete="off" inputmode="numeric" maxlength="6" /><div class="auth-captcha-img" id="reset-captcha-img" title="看不清?点击刷新"></div></div>
|
|
450
|
+
</div>
|
|
451
|
+
<div class="auth-field"><label>短信验证码</label>
|
|
452
|
+
<div class="auth-sms"><input class="auth-input" id="reset-sms-code" type="text" placeholder="6 位验证码" autocomplete="off" inputmode="numeric" maxlength="6" /><button class="auth-send-btn" id="btn-send-reset-sms" type="button">获取验证码</button></div>
|
|
453
|
+
</div>
|
|
454
|
+
<div class="auth-field"><label>新密码</label><input class="auth-input" id="reset-pass" type="password" placeholder="至少 8 位" autocomplete="new-password" /></div>
|
|
455
|
+
<div class="auth-hint">⚠️ 修改后所有已授权设备/会话将失效,需重新登录与解锁;端到端加密的旧会话无法恢复。</div>
|
|
456
|
+
<button class="auth-btn" id="btn-password-reset">重置密码</button>
|
|
457
|
+
<div class="auth-msg" id="reset-msg"></div>
|
|
458
|
+
<div style="text-align:center;margin-top:8px;"><button class="auth-link" id="btn-back-to-login" type="button">← 返回登录</button></div>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
<!-- 设备列表视图(实时在线设备选择 + 设备管理) -->
|
|
464
|
+
<div id="view-devices" class="hidden">
|
|
465
|
+
<div class="card">
|
|
466
|
+
<h2><span class="dot" id="dot-signal"></span><span id="signal-label">我的设备</span></h2>
|
|
467
|
+
<div id="device-list"></div>
|
|
468
|
+
<button id="btn-refresh" class="secondary">刷新设备列表</button>
|
|
469
|
+
<button id="btn-manage-devices" class="secondary" style="margin-top:8px;">管理设备(移除/换机)</button>
|
|
470
|
+
<div id="manage-device-list" class="hidden"></div>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
<div class="card usage-card">
|
|
474
|
+
<b>使用说明</b><br/>
|
|
475
|
+
1. 电脑上运行 <code>dsh-bridge</code> 并保持在线;<br/>
|
|
476
|
+
2. 列表展示你的全部已绑定设备及实时状态,🟢 在线设备可直接点选进入,灰色为离线;<br/>
|
|
477
|
+
3. 进入后加载电脑端 <b>dsh web 原生界面</b>(与电脑本地一致,含设置/凭据)。<br/>
|
|
478
|
+
点选设备进入;单台在线也需点选确认,不会自动跳转。
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
</main>
|
|
483
|
+
|
|
484
|
+
<!-- E2EE(Phase-3)解锁层:仅在「账号启用 e2ee ∧ 设备声明 e2ee-v2」时出现。
|
|
485
|
+
密钥/MK 仅存本页内存;输入即触发 WebCrypto 派生 + /_e2ee/ctrl 握手(见 JS)。 -->
|
|
486
|
+
<div class="modal-overlay hidden" id="unlock-modal">
|
|
487
|
+
<div class="modal-card">
|
|
488
|
+
<h3>🔒 端到端加密解锁</h3>
|
|
489
|
+
<div class="e2ee-unlock-who">设备:<b id="unlock-device-name">-</b></div>
|
|
490
|
+
<div class="e2ee-unlock-copy" id="unlock-copy">请输入账号密码以开启 🔒 端到端加密。密码仅在本页内存中参与密钥派生,不会发送给中继,也不被保存;刷新或重开页面需再次输入。</div>
|
|
491
|
+
<div class="auth-field"><label for="unlock-pass">账号密码</label><input class="auth-input" id="unlock-pass" type="password" placeholder="登录密码(≥8 位)" autocomplete="current-password" /></div>
|
|
492
|
+
<div class="e2ee-unlock-msg" id="unlock-msg"></div>
|
|
493
|
+
<div class="e2ee-unlock-row">
|
|
494
|
+
<button type="button" class="auth-btn ghost" id="btn-unlock-skip">暂不加密(明文)</button>
|
|
495
|
+
<button type="button" class="auth-btn" id="btn-unlock-go">解锁并建立加密会话</button>
|
|
496
|
+
</div>
|
|
497
|
+
<div class="e2ee-unlock-note">忘记/重置密码后,旧的加密会话无法恢复(服务端不保存你的内容,无法代为解密)。</div>
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<div class="toast" id="toast"></div>
|
|
502
|
+
|
|
503
|
+
<script>
|
|
504
|
+
"use strict";
|
|
505
|
+
/* ============================================================
|
|
506
|
+
* native.html — dsh-remote 手机端 PWA(隧道模式)
|
|
507
|
+
*
|
|
508
|
+
* 架构:
|
|
509
|
+
* 本页(手机,公开地址 /app/)只做三件事:登录/注册、查实时在线设备、跳转。
|
|
510
|
+
* 进入设备后浏览器直接加载 https://<你的域名>/ 根路径,
|
|
511
|
+
* nginx → relay-router → bridge WebSocket 隧道 → 电脑端 dsh web(127.0.0.1:3080),
|
|
512
|
+
* 页面体验与电脑本地一致(WebRTC/DataChannel 时代已废弃删除)。
|
|
513
|
+
*
|
|
514
|
+
* 流程:
|
|
515
|
+
* 登录/注册/会话恢复 → GET /_devices(router 实时 WS Map,校验 dsh_token cookie)
|
|
516
|
+
* 0/1/多台在线 → 一律进设备选择页(0 台展示安装引导),由用户点选设备
|
|
517
|
+
* enterDevice(id) 写 dsh_device cookie 后跳 /
|
|
518
|
+
* 忘记密码(短信重置):登录卡入口 → POST /api/password/reset,成功后清 token/cookie/threads 回登录
|
|
519
|
+
* 显式退出(/app/?logout=1)→ 先清 localStorage/Cookie 再渲染登录页
|
|
520
|
+
* ============================================================ */
|
|
521
|
+
|
|
522
|
+
// ---------- 配置(默认按页面 origin 推断;public-config 再覆盖 api 地址) ----------
|
|
523
|
+
const origin = window.location.origin;
|
|
524
|
+
let API_BASE = (window.DSH_RELAY_API || origin + "/relay-api").replace(/\/+$/, "");
|
|
525
|
+
/** 公共配置缓存(域名/价格/试用/邀请规则/安装教程)。 */
|
|
526
|
+
let pubConfig = null;
|
|
527
|
+
/** 部署模式:saas = 云端账号体系(手机号登录);self = 自建部署(访问密钥登录)。 */
|
|
528
|
+
let deploymentMode = "saas";
|
|
529
|
+
|
|
530
|
+
async function loadPublicConfig() {
|
|
531
|
+
try {
|
|
532
|
+
const r = await fetch(API_BASE + "/api/public-config", { signal: AbortSignal.timeout(6000) });
|
|
533
|
+
const d = await r.json();
|
|
534
|
+
if (d.api_url) API_BASE = d.api_url.replace(/\/+$/, "");
|
|
535
|
+
pubConfig = d;
|
|
536
|
+
deploymentMode = "saas";
|
|
537
|
+
} catch {
|
|
538
|
+
// 无 enterprise public-config → 自建部署:仅访问密钥登录,无账号体系
|
|
539
|
+
deploymentMode = "self";
|
|
540
|
+
}
|
|
541
|
+
applyDeploymentMode();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 按部署模式隔离登录 UI:SaaS 只保留手机号登录/注册;自建只保留访问密钥。
|
|
545
|
+
* 登录/注册表单的显隐统一交给 switchAuthTab 管理,这里只切模式相关元素。 */
|
|
546
|
+
function applyDeploymentMode() {
|
|
547
|
+
const self = deploymentMode === "self";
|
|
548
|
+
const title = $("auth-login-title");
|
|
549
|
+
const hint = $("auth-hint-login");
|
|
550
|
+
if (title) title.textContent = self ? "自建服务登录" : "远程控制";
|
|
551
|
+
if (hint) hint.textContent = self
|
|
552
|
+
? "输入服务器访问密钥即可登录(密钥在你部署服务器的配置中生成,如 open.env)。"
|
|
553
|
+
: "登录后可远程控制电脑上的 dsh web,体验与本地一致。";
|
|
554
|
+
// 自建:只显示密钥表单;SaaS:隐藏密钥入口
|
|
555
|
+
$("login-mode-local").classList.toggle("hidden", !self);
|
|
556
|
+
$("btn-toggle-login-local").classList.toggle("hidden", !self);
|
|
557
|
+
$("btn-toggle-login-sms").classList.toggle("hidden", self);
|
|
558
|
+
// 忘记密码(短信重置)属 SaaS 账号体系:自建模式隐藏
|
|
559
|
+
const forgotPass = $("btn-forgot-pass");
|
|
560
|
+
if (forgotPass) forgotPass.closest("div").classList.toggle("hidden", self);
|
|
561
|
+
const phoneField = $("login-phone")?.closest(".auth-field");
|
|
562
|
+
if (phoneField) phoneField.classList.toggle("hidden", self);
|
|
563
|
+
// 密码/短信表单互斥:自建模式两者都隐藏;SaaS 模式按当前 loginMode 显示其一
|
|
564
|
+
$("login-mode-pass").classList.toggle("hidden", self || loginMode !== "pass");
|
|
565
|
+
$("login-mode-sms").classList.toggle("hidden", self || loginMode !== "sms");
|
|
566
|
+
$("btn-login").classList.toggle("hidden", self);
|
|
567
|
+
$("tab-register").classList.toggle("hidden", self);
|
|
568
|
+
// 自建模式不展示升级/邀请相关按钮
|
|
569
|
+
// 恢复当前 tab 的登录/注册表单显隐(自建模式强制登录 tab)
|
|
570
|
+
const isLoginTab = $("tab-login").classList.contains("active");
|
|
571
|
+
switchAuthTab(self || isLoginTab ? "login" : "register");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const $ = (id) => document.getElementById(id);
|
|
575
|
+
const state = {
|
|
576
|
+
token: localStorage.getItem("dsh_token") || "",
|
|
577
|
+
user: JSON.parse(localStorage.getItem("dsh_user") || "null")
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// ---------- 工具 ----------
|
|
581
|
+
function toast(msg, ms = 2500) {
|
|
582
|
+
const t = $("toast"); t.textContent = msg; t.classList.add("show");
|
|
583
|
+
setTimeout(() => t.classList.remove("show"), ms);
|
|
584
|
+
}
|
|
585
|
+
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); }
|
|
586
|
+
/** 当前渲染出的在线设备数(0 = 需要自动等待上线,见 scheduleDevicePoll)。 */
|
|
587
|
+
let lastOnlineCount = 0;
|
|
588
|
+
|
|
589
|
+
function setView(v) {
|
|
590
|
+
["boot", "login", "devices"].forEach(n => $(`view-${n}`).classList.toggle("hidden", n !== v));
|
|
591
|
+
// 进入登录页时刷新所有当前可见的验证码,保证永远是新的、无需手动点击
|
|
592
|
+
if (v === "login") refreshVisibleCaptchas();
|
|
593
|
+
// 离开设备页 → 停掉自动探测;进入设备页 → 清零计数(下一次刷新重新决定是否轮询)
|
|
594
|
+
if (v === "devices") resetDevicePoll(); else cancelDevicePoll();
|
|
595
|
+
}
|
|
596
|
+
/** 刷新当前可见的图形验证码(密码/短信/注册/重置各自的验证码区)。 */
|
|
597
|
+
function refreshVisibleCaptchas() {
|
|
598
|
+
if (!$("login-captcha-row").classList.contains("hidden")) loadCaptchaInto("login-captcha-img");
|
|
599
|
+
if (!$("login-sms-captcha-row").classList.contains("hidden")) loadCaptchaInto("login-sms-captcha-img");
|
|
600
|
+
if (!$("reg-sms-captcha-row").classList.contains("hidden")) loadCaptchaInto("reg-sms-captcha-img");
|
|
601
|
+
const resetForm = $("auth-reset-form");
|
|
602
|
+
if (resetForm && !resetForm.classList.contains("hidden") && !$("reset-captcha-row").classList.contains("hidden")) loadCaptchaInto("reset-captcha-img");
|
|
603
|
+
}
|
|
604
|
+
function setConn(text, ok) {
|
|
605
|
+
$("conn-status").textContent = text;
|
|
606
|
+
$("conn-status").style.color = ok ? "var(--ok)" : "var(--muted)";
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ---------- 账号 API ----------
|
|
610
|
+
async function api(path, body, method = "POST") {
|
|
611
|
+
const r = await fetch(API_BASE + path, {
|
|
612
|
+
method,
|
|
613
|
+
headers: { "content-type": "application/json", ...(state.token ? { authorization: `Bearer ${state.token}` } : {}) },
|
|
614
|
+
body: body ? JSON.stringify(body) : undefined
|
|
615
|
+
});
|
|
616
|
+
return { status: r.status, data: await r.json().catch(() => ({})) };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ---------- 图形验证码(通用:加载到指定 img,id 存 dataset) ----------
|
|
620
|
+
async function loadCaptchaInto(imgElId) {
|
|
621
|
+
const imgEl = $(imgElId);
|
|
622
|
+
imgEl.innerHTML = '<span class="auth-hint">加载中</span>';
|
|
623
|
+
delete imgEl.dataset.captchaId;
|
|
624
|
+
try {
|
|
625
|
+
const r = await fetch(API_BASE + "/api/captcha");
|
|
626
|
+
if (!r.ok) throw new Error("http " + r.status);
|
|
627
|
+
const d = await r.json();
|
|
628
|
+
imgEl.dataset.captchaId = d.captcha_id;
|
|
629
|
+
imgEl.innerHTML = d.svg || "";
|
|
630
|
+
} catch {
|
|
631
|
+
imgEl.innerHTML = '<span class="auth-hint">点击重试</span>';
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const validPhone = (p) => /^1\d{10}$/.test((p || "").trim());
|
|
635
|
+
|
|
636
|
+
// ---------- 登录/注册 tabs ----------
|
|
637
|
+
function switchAuthTab(tab) {
|
|
638
|
+
const isLogin = tab === "login";
|
|
639
|
+
$("tab-login").classList.toggle("active", isLogin);
|
|
640
|
+
$("tab-register").classList.toggle("active", !isLogin);
|
|
641
|
+
$("auth-login-form").classList.toggle("hidden", !isLogin);
|
|
642
|
+
$("auth-register-form").classList.toggle("hidden", isLogin);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ---------- 登录态渲染 ----------
|
|
646
|
+
/** 手机号中间 4 位打码:185****7193(非手机号原样返回)。 */
|
|
647
|
+
function maskPhone(p) {
|
|
648
|
+
const s = String(p || "");
|
|
649
|
+
return /^1\d{10}$/.test(s) ? s.slice(0, 3) + "****" + s.slice(7) : s;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function renderAuthState() {
|
|
653
|
+
const loggedIn = !!state.token;
|
|
654
|
+
$("auth-logged-in").classList.toggle("hidden", !loggedIn);
|
|
655
|
+
$("auth-logged-out").classList.toggle("hidden", loggedIn);
|
|
656
|
+
$("btn-logout").classList.toggle("hidden", !loggedIn); // 登录后 header 显示退出按钮
|
|
657
|
+
if (loggedIn && state.user) {
|
|
658
|
+
const who = state.user.phone ? maskPhone(state.user.phone) : (state.user.email || "D");
|
|
659
|
+
$("auth-email").textContent = who;
|
|
660
|
+
$("auth-plan").textContent = state.user.plan ? `套餐: ${state.user.plan}` : "";
|
|
661
|
+
$("auth-avatar").textContent = who.charAt(0).toUpperCase();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* 登录/注册/会话恢复统一入口:写 dsh_token cookie 后进入设备选择页,
|
|
667
|
+
* 由 Router 实时设备端点 GET /_devices 列出当前 WebSocket 在线的设备
|
|
668
|
+
* (不再依赖数据库 online 字段;也不再按台数自动跳转)。
|
|
669
|
+
* 0 台在线 → 选择页展示安装引导空状态(可刷新);
|
|
670
|
+
* 1/多台在线 → 展示实时列表,均由用户点选进入。
|
|
671
|
+
*/
|
|
672
|
+
async function enterMirror(token) {
|
|
673
|
+
writeSessionCookie("dsh_token", token);
|
|
674
|
+
renderAuthState();
|
|
675
|
+
if (state.user) setConn(maskPhone(state.user.phone) || state.user.email || "已连接", true);
|
|
676
|
+
// 0/1/多台在线:一律进设备列表视图,由用户点选进入,不自动跳转
|
|
677
|
+
setView("devices");
|
|
678
|
+
refreshDevices();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 会话 cookie 的有效期 = **令牌自己的 exp**(2026-09-19 修复)。
|
|
683
|
+
* 旧实现写死 2 小时:自建部署的令牌有效期可配(默认 2h,自签可更长),
|
|
684
|
+
* cookie 一旦先失效,设备列表 /_devices 与镜像页就全部 401 ——
|
|
685
|
+
* 用户看到的是"过一段时间就失效、还报错"。现在两者同生共死。
|
|
686
|
+
*/
|
|
687
|
+
function sessionMaxAgeSeconds(token, fallbackSeconds = 2 * 3600) {
|
|
688
|
+
try {
|
|
689
|
+
const parts = String(token || "").split(".");
|
|
690
|
+
if (parts.length !== 3) return fallbackSeconds;
|
|
691
|
+
const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
692
|
+
if (typeof payload.exp !== "number") return fallbackSeconds;
|
|
693
|
+
const left = payload.exp - Math.floor(Date.now() / 1000);
|
|
694
|
+
return left > 0 ? left : 0;
|
|
695
|
+
} catch { return fallbackSeconds; }
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** 写 dsh_token cookie(router 校验它;有效期随令牌 exp)。 */
|
|
699
|
+
function writeSessionCookie(name, token) {
|
|
700
|
+
const maxAge = sessionMaxAgeSeconds(token);
|
|
701
|
+
document.cookie = `${name}=${encodeURIComponent(token)}; path=/; max-age=${maxAge}; SameSite=Lax; Secure`;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** 写 dsh_device cookie(标明"当前选的是哪台设备",供绝对路径请求兜底);有效期与令牌对齐。 */
|
|
705
|
+
function writeDeviceCookie(deviceId) {
|
|
706
|
+
document.cookie = `dsh_device=${encodeURIComponent(deviceId)}; path=/; max-age=${sessionMaxAgeSeconds(state.token)}; SameSite=Lax; Secure`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** 选定设备:写 dsh_device cookie 后进该设备的镜像页(经 router 的 /remote/<id>/ 路由)。 */
|
|
710
|
+
function enterDevice(deviceId) {
|
|
711
|
+
writeDeviceCookie(deviceId);
|
|
712
|
+
// Phase-4:跳镜像页前写/清一次性 E2EE 交接单(解锁会话→交接;明文/未解锁→清除防串台)
|
|
713
|
+
dshE2eeHandoverSync(deviceId);
|
|
714
|
+
// 2026-09-19 起跳 /remote/<deviceId>/ 而不是根路径 "/":
|
|
715
|
+
// ① 以前地址栏里只剩端口,看不出在哪台设备上(用户反馈"点进去之后路径没有了");
|
|
716
|
+
// ② 刷新时若 dsh_device cookie 已过期,根路径只剩一句纯文本 404(白屏)。
|
|
717
|
+
window.location.replace("/remote/" + encodeURIComponent(deviceId) + "/");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* 处理 Router 的引导回跳(/app/?reason=…,2026-09-19)。
|
|
722
|
+
* 用户反馈:「会话过期/设备不在线时应该回到设备拉取的那个界面」「不能让用户直接遇到白屏报错」。
|
|
723
|
+
* Router 不再渲染报错页,而是把用户送回这里 —— 本函数负责说清"发生了什么"和"该做什么"。
|
|
724
|
+
*/
|
|
725
|
+
function handleEntryReason(reason) {
|
|
726
|
+
if (!reason) return;
|
|
727
|
+
const p = new URLSearchParams(window.location.search);
|
|
728
|
+
const device = p.get("device") || "";
|
|
729
|
+
const name = p.get("name") || device;
|
|
730
|
+
const text = {
|
|
731
|
+
expired: "登录状态已过期,已为你返回设备列表。请重新点选设备进入;若仍提示过期,请重新登录。",
|
|
732
|
+
offline: `「${name || "该设备"}」当前离线:电脑端的 dsh-bridge 没有在运行,或网络不通。请在电脑上确认 dsh-remote 正在运行后重试。`,
|
|
733
|
+
forbidden: "该设备不属于当前账号(或访问密钥已更换),已返回设备列表,请重新选择。",
|
|
734
|
+
unknown_device: "设备不存在或已被移除,已返回设备列表,请重新选择。"
|
|
735
|
+
}[reason];
|
|
736
|
+
try { history.replaceState(null, "", "/app/"); } catch { /* 忽略 */ }
|
|
737
|
+
if (!text) return;
|
|
738
|
+
// 提示条挂在 <main> 顶部 → 登录视图(令牌真失效时)与设备列表视图都能看到,
|
|
739
|
+
// 不依赖 toast 那几秒;toast 只在提示条元素缺失时兜底。
|
|
740
|
+
const box = $("entry-hint"), msg = $("entry-hint-msg"), ico = $("entry-hint-ico");
|
|
741
|
+
if (!box || !msg) { toast(text, 6000); return; }
|
|
742
|
+
if (ico) ico.textContent = reason === "offline" ? "💤" : reason === "expired" ? "🔐" : "ℹ️";
|
|
743
|
+
box.className = "card entry-hint warn";
|
|
744
|
+
msg.textContent = text;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ---------- v2 设备管理(移除/换机;pro 每月 3 次,pro_max 不限) ----------
|
|
748
|
+
|
|
749
|
+
let managingDevices = false;
|
|
750
|
+
async function toggleManageDevices() {
|
|
751
|
+
managingDevices = !managingDevices;
|
|
752
|
+
const wrap = $("manage-device-list");
|
|
753
|
+
const btn = $("btn-manage-devices");
|
|
754
|
+
if (!managingDevices) {
|
|
755
|
+
wrap.classList.add("hidden");
|
|
756
|
+
btn.textContent = "管理设备(移除/换机)";
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
btn.textContent = "收起管理";
|
|
760
|
+
wrap.classList.remove("hidden");
|
|
761
|
+
wrap.innerHTML = '<div class="empty">加载中...</div>';
|
|
762
|
+
try {
|
|
763
|
+
const r = await api("/api/devices", null, "GET");
|
|
764
|
+
const devices = (Array.isArray(r.data.devices) ? r.data.devices : []).filter(x => x && x.id);
|
|
765
|
+
if (devices.length === 0) {
|
|
766
|
+
wrap.innerHTML = '<div class="empty">尚未绑定任何设备。</div>';
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
wrap.innerHTML = "";
|
|
770
|
+
devices.forEach(d => {
|
|
771
|
+
const el = document.createElement("div");
|
|
772
|
+
el.className = "device";
|
|
773
|
+
el.style.opacity = ".85";
|
|
774
|
+
el.innerHTML = `
|
|
775
|
+
<div class="icon">💻</div>
|
|
776
|
+
<div>
|
|
777
|
+
<div class="name">${esc(d.device_name || d.id)}</div>
|
|
778
|
+
<div class="meta">${esc(d.id)} · ${d.online ? "在线" : "离线"}</div>
|
|
779
|
+
</div>
|
|
780
|
+
<button class="secondary" style="margin:0;padding:8px 12px;width:auto;flex-shrink:0;" data-remove="${esc(d.id)}">移除</button>`;
|
|
781
|
+
wrap.appendChild(el);
|
|
782
|
+
});
|
|
783
|
+
wrap.querySelectorAll("[data-remove]").forEach(btn2 => {
|
|
784
|
+
btn2.onclick = async () => {
|
|
785
|
+
const id = btn2.dataset.remove;
|
|
786
|
+
btn2.disabled = true; btn2.textContent = "移除中...";
|
|
787
|
+
try {
|
|
788
|
+
const res = await api(`/api/devices/${encodeURIComponent(id)}`, null, "DELETE");
|
|
789
|
+
if (res.status === 200) {
|
|
790
|
+
toast("设备已移除");
|
|
791
|
+
await toggleManageDevices();
|
|
792
|
+
toggleManageDevices();
|
|
793
|
+
} else {
|
|
794
|
+
toast(res.data?.error?.message || `移除失败(${res.status})`, 4500);
|
|
795
|
+
btn2.disabled = false; btn2.textContent = "移除";
|
|
796
|
+
}
|
|
797
|
+
} catch (e) {
|
|
798
|
+
toast("移除失败: " + e.message, 4000);
|
|
799
|
+
btn2.disabled = false; btn2.textContent = "移除";
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
});
|
|
803
|
+
} catch (e) {
|
|
804
|
+
wrap.innerHTML = `<div class="empty">获取设备列表失败:${esc(e.message)}</div>`;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ---------- 短信验证码(发送 + 倒计时 + 防刷图形验证码) ----------
|
|
809
|
+
function setMsgEl(id, cls, text) { const m = $(id); m.className = "auth-msg " + cls; m.textContent = text; }
|
|
810
|
+
function startCountdown(btn) {
|
|
811
|
+
let s = 60; btn.disabled = true;
|
|
812
|
+
const tick = () => {
|
|
813
|
+
btn.textContent = `${s}s`;
|
|
814
|
+
if (--s < 0) { btn.disabled = false; btn.textContent = "获取验证码"; return; }
|
|
815
|
+
setTimeout(tick, 1000);
|
|
816
|
+
};
|
|
817
|
+
tick();
|
|
818
|
+
}
|
|
819
|
+
/** 发送短信验证码的场景 UI 映射:登录/注册/重置(忘记密码)共用同一 /api/sms-code 端点。 */
|
|
820
|
+
const SMS_UI = {
|
|
821
|
+
login: { phoneId: "login-phone", msgId: "login-msg", btnId: "btn-send-login-sms", capRowId: "login-sms-captcha-row", capInputId: "login-sms-captcha", capImgId: "login-sms-captcha-img" },
|
|
822
|
+
register: { phoneId: "reg-phone", msgId: "reg-msg", btnId: "btn-send-reg-sms", capRowId: "reg-sms-captcha-row", capInputId: "reg-sms-captcha", capImgId: "reg-sms-captcha-img" },
|
|
823
|
+
reset: { phoneId: "reset-phone", msgId: "reset-msg", btnId: "btn-send-reset-sms", capRowId: "reset-captcha-row", capInputId: "reset-captcha", capImgId: "reset-captcha-img" }
|
|
824
|
+
};
|
|
825
|
+
async function sendSms(kind) {
|
|
826
|
+
const ui = SMS_UI[kind] || SMS_UI.register;
|
|
827
|
+
const phone = $(ui.phoneId).value.trim();
|
|
828
|
+
const msgId = ui.msgId;
|
|
829
|
+
if (!validPhone(phone)) { setMsgEl(msgId, "err", "请输入正确的手机号"); return; }
|
|
830
|
+
const btn = $(ui.btnId);
|
|
831
|
+
const capRow = $(ui.capRowId);
|
|
832
|
+
const capInput = $(ui.capInputId);
|
|
833
|
+
const capImg = $(ui.capImgId);
|
|
834
|
+
|
|
835
|
+
const payload = { phone };
|
|
836
|
+
// 防刷:验证码行可见时,需带图形验证码
|
|
837
|
+
if (!capRow.classList.contains("hidden")) {
|
|
838
|
+
if (!capImg.dataset.captchaId || !capInput.value.trim()) { setMsgEl(msgId, "err", "请输入图中验证码"); return; }
|
|
839
|
+
payload.captcha_id = capImg.dataset.captchaId;
|
|
840
|
+
payload.captcha_answer = capInput.value.trim();
|
|
841
|
+
}
|
|
842
|
+
setMsgEl(msgId, "", "发送中...");
|
|
843
|
+
try {
|
|
844
|
+
const r = await fetch(API_BASE + "/api/sms-code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
|
845
|
+
const d = await r.json().catch(() => ({}));
|
|
846
|
+
if (r.status === 200) {
|
|
847
|
+
startCountdown(btn);
|
|
848
|
+
// 测试模式:服务端可能返回 test_code,展示出来方便自测
|
|
849
|
+
if (d.test_code) setMsgEl(msgId, "ok", `验证码已发送(测试码: ${d.test_code})`);
|
|
850
|
+
else setMsgEl(msgId, "ok", "验证码已发送");
|
|
851
|
+
capRow.classList.add("hidden"); capInput.value = "";
|
|
852
|
+
} else if (r.status === 400 && /验证码|captcha/i.test(d.error?.message || "")) {
|
|
853
|
+
capRow.classList.remove("hidden");
|
|
854
|
+
loadCaptchaInto(capImg.id);
|
|
855
|
+
setMsgEl(msgId, "err", "发送频繁,请先输入图形验证码");
|
|
856
|
+
} else {
|
|
857
|
+
setMsgEl(msgId, "err", d.error?.message || `发送失败(${r.status})`);
|
|
858
|
+
}
|
|
859
|
+
} catch (e) { setMsgEl(msgId, "err", "发送失败: " + e.message); }
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ---------- 登录(密码 / 短信 / 本地密钥 三选一,密码为默认) ----------
|
|
863
|
+
let loginMode = "pass";
|
|
864
|
+
function toggleLoginMode() {
|
|
865
|
+
loginMode = loginMode === "pass" ? "sms" : "pass";
|
|
866
|
+
$("login-mode-pass").classList.toggle("hidden", loginMode !== "pass");
|
|
867
|
+
$("login-mode-sms").classList.toggle("hidden", loginMode !== "sms");
|
|
868
|
+
$("login-mode-local").classList.add("hidden");
|
|
869
|
+
$("btn-toggle-login-sms").textContent = loginMode === "pass" ? "验证码登录" : "密码登录";
|
|
870
|
+
refreshVisibleCaptchas();
|
|
871
|
+
}
|
|
872
|
+
function toggleLocalLogin() {
|
|
873
|
+
const show = $("login-mode-local").classList.contains("hidden");
|
|
874
|
+
$("login-mode-local").classList.toggle("hidden", !show);
|
|
875
|
+
if (show) {
|
|
876
|
+
$("login-mode-pass").classList.add("hidden");
|
|
877
|
+
$("login-mode-sms").classList.add("hidden");
|
|
878
|
+
} else {
|
|
879
|
+
$("login-mode-pass").classList.remove("hidden");
|
|
880
|
+
loginMode = "pass";
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/** 开源自部署:访问密钥登录(本地认证 POST /_login,免账号体系)。 */
|
|
884
|
+
async function doLocalLogin() {
|
|
885
|
+
const key = $("login-local-key").value.trim();
|
|
886
|
+
const msg = $("login-msg");
|
|
887
|
+
if (!key) { msg.className = "auth-msg err"; msg.textContent = "请输入访问密钥"; return; }
|
|
888
|
+
msg.className = "auth-msg"; msg.innerHTML = '<span class="loading"></span> 登录中...';
|
|
889
|
+
try {
|
|
890
|
+
const r = await fetch("/_login", {
|
|
891
|
+
method: "POST",
|
|
892
|
+
headers: { "content-type": "application/json" },
|
|
893
|
+
body: JSON.stringify({ key })
|
|
894
|
+
});
|
|
895
|
+
const d = await r.json().catch(() => ({}));
|
|
896
|
+
if (r.status === 200 && d.token) {
|
|
897
|
+
state.token = d.token;
|
|
898
|
+
state.user = { phone: "自建服务", plan: d.plan || "pro_max" };
|
|
899
|
+
localStorage.setItem("dsh_token", state.token);
|
|
900
|
+
localStorage.setItem("dsh_user", JSON.stringify(state.user));
|
|
901
|
+
msg.className = "auth-msg ok"; msg.textContent = "✅ 登录成功,正在进入...";
|
|
902
|
+
enterMirror(d.token);
|
|
903
|
+
} else {
|
|
904
|
+
msg.className = "auth-msg err";
|
|
905
|
+
msg.textContent = d.error?.message || `登录失败(${r.status})`;
|
|
906
|
+
}
|
|
907
|
+
} catch (e) {
|
|
908
|
+
msg.className = "auth-msg err";
|
|
909
|
+
msg.textContent = "无法连接本地认证服务: " + e.message + "(请确认访问的是自建服务地址)";
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
async function doLogin() {
|
|
913
|
+
const phone = $("login-phone").value.trim();
|
|
914
|
+
const msg = $("login-msg");
|
|
915
|
+
if (!validPhone(phone)) { msg.className = "auth-msg err"; msg.textContent = "请输入正确的手机号"; return; }
|
|
916
|
+
let res;
|
|
917
|
+
if (loginMode === "pass") {
|
|
918
|
+
const password = $("login-pass").value;
|
|
919
|
+
if (!password) { msg.className = "auth-msg err"; msg.textContent = "请输入密码"; return; }
|
|
920
|
+
// 渐进式验证码:行可见时才要求输入并随请求提交
|
|
921
|
+
const capRow = $("login-captcha-row");
|
|
922
|
+
const payload = { phone, password };
|
|
923
|
+
if (!capRow.classList.contains("hidden")) {
|
|
924
|
+
const cid = $("login-captcha-img").dataset.captchaId, ctext = $("login-captcha").value.trim();
|
|
925
|
+
if (!cid || !ctext) { msg.className = "auth-msg err"; msg.textContent = "请输入图中验证码"; if (!cid) loadCaptchaInto("login-captcha-img"); return; }
|
|
926
|
+
payload.captcha_id = cid;
|
|
927
|
+
payload.captcha_answer = ctext;
|
|
928
|
+
}
|
|
929
|
+
msg.className = "auth-msg"; msg.innerHTML = '<span class="loading"></span> 登录中...';
|
|
930
|
+
res = await api("/api/login", payload);
|
|
931
|
+
} else {
|
|
932
|
+
const sms = $("login-sms-code").value.trim();
|
|
933
|
+
if (!sms) { msg.className = "auth-msg err"; msg.textContent = "请输入短信验证码"; return; }
|
|
934
|
+
msg.className = "auth-msg"; msg.innerHTML = '<span class="loading"></span> 登录中...';
|
|
935
|
+
res = await api("/api/login-sms", { phone, sms_code: sms });
|
|
936
|
+
}
|
|
937
|
+
if (res.status === 200) {
|
|
938
|
+
state.token = res.data.token; state.user = res.data.user;
|
|
939
|
+
localStorage.setItem("dsh_token", state.token);
|
|
940
|
+
localStorage.setItem("dsh_user", JSON.stringify(state.user));
|
|
941
|
+
// 免二次输密码:密码登录时在内存保留一份(仅本页会话),随即清空输入框(卫生:不再残留 DOM)
|
|
942
|
+
if (loginMode === "pass") {
|
|
943
|
+
const pw = $("login-pass").value;
|
|
944
|
+
if (pw) { e2eePendingPassword = pw; $("login-pass").value = ""; }
|
|
945
|
+
}
|
|
946
|
+
msg.className = "auth-msg ok"; msg.textContent = "✅ 登录成功,正在进入...";
|
|
947
|
+
enterMirror(res.data.token);
|
|
948
|
+
} else {
|
|
949
|
+
msg.className = "auth-msg err";
|
|
950
|
+
// 服务端要求图形验证码(首次提交缺验证码/失败过多):展示验证码行并加载新图
|
|
951
|
+
if (res.data?.error?.code === "captcha_invalid") {
|
|
952
|
+
const capRow = $("login-captcha-row");
|
|
953
|
+
capRow.classList.remove("hidden");
|
|
954
|
+
loadCaptchaInto("login-captcha-img");
|
|
955
|
+
msg.textContent = "请输入图形验证码后重试";
|
|
956
|
+
} else {
|
|
957
|
+
msg.textContent = res.data?.error?.message || `登录失败(${res.status})`;
|
|
958
|
+
// 验证码行可见时失败,刷新新图
|
|
959
|
+
if (loginMode === "pass" && !$("login-captcha-row").classList.contains("hidden")) loadCaptchaInto("login-captcha-img");
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function doRegister() {
|
|
965
|
+
const phone = $("reg-phone").value.trim();
|
|
966
|
+
const sms = $("reg-sms-code").value.trim();
|
|
967
|
+
const password = $("reg-pass").value, password2 = $("reg-pass2").value;
|
|
968
|
+
const msg = $("reg-msg");
|
|
969
|
+
if (!validPhone(phone)) { msg.className = "auth-msg err"; msg.textContent = "请输入正确的手机号"; return; }
|
|
970
|
+
if (!sms) { msg.className = "auth-msg err"; msg.textContent = "请输入短信验证码"; return; }
|
|
971
|
+
if (password.length < 8) { msg.className = "auth-msg err"; msg.textContent = "密码至少 8 位"; return; }
|
|
972
|
+
if (password !== password2) { msg.className = "auth-msg err"; msg.textContent = "两次输入的密码不一致"; return; }
|
|
973
|
+
msg.className = "auth-msg"; msg.innerHTML = '<span class="loading"></span> 注册中...';
|
|
974
|
+
// reg_source:注册来源上报 —— 手机端网页注册固定为 remoteweb_register。
|
|
975
|
+
// 可选新增字段:服务端旧版本忽略未知字段,无需版本判断;手机端不是安装方,不传安装来源。
|
|
976
|
+
const payload = { phone, sms_code: sms, password, reg_source: "remoteweb_register" };
|
|
977
|
+
// 邀请链接携带的邀请码(注册后首次绑设备计有效邀请)
|
|
978
|
+
if (window.__inviteCode) payload.invite_code = window.__inviteCode;
|
|
979
|
+
const res = await api("/api/register", payload);
|
|
980
|
+
if (res.status === 201) {
|
|
981
|
+
state.token = res.data.token; state.user = res.data.user;
|
|
982
|
+
localStorage.setItem("dsh_token", state.token);
|
|
983
|
+
localStorage.setItem("dsh_user", JSON.stringify(state.user));
|
|
984
|
+
// 注册即登录:同样内存保留密码用于免二次解锁,随即清空密码输入框
|
|
985
|
+
if (password) { e2eePendingPassword = password; $("reg-pass").value = ""; $("reg-pass2").value = ""; }
|
|
986
|
+
msg.className = "auth-msg ok"; msg.textContent = "✅ 注册成功,已自动登录,正在进入...";
|
|
987
|
+
enterMirror(res.data.token);
|
|
988
|
+
} else {
|
|
989
|
+
msg.className = "auth-msg err";
|
|
990
|
+
msg.textContent = res.data?.error?.message || `注册失败(${res.status})`;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ---------- 忘记密码(短信重置)→ POST /api/password/reset ----------
|
|
995
|
+
// 企业端契约(body {phone,sms_code,new_password} → 200 {ok:true};错误取 error.message 可读展示)。
|
|
996
|
+
// 手机号 + 图形验证码 + 短信验证码 + 新密码(≥8);成功后全部本地 token/cookie/threads 作废,用新密码重新登录。
|
|
997
|
+
|
|
998
|
+
/** 打开重置密码小表单(覆盖登录卡):进入即加载图形验证码供发码防刷。 */
|
|
999
|
+
function openResetForm() {
|
|
1000
|
+
const m = $("reset-msg");
|
|
1001
|
+
if (m) { m.className = "auth-msg"; m.textContent = ""; }
|
|
1002
|
+
$("auth-logged-in").classList.add("hidden");
|
|
1003
|
+
$("auth-logged-out").classList.add("hidden");
|
|
1004
|
+
$("auth-reset-form").classList.remove("hidden");
|
|
1005
|
+
loadCaptchaInto("reset-captcha-img");
|
|
1006
|
+
setTimeout(() => { try { $("reset-phone").focus(); } catch {} }, 60);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** 从重置小表单返回登录卡:恢复 renderAuthState 决定的已登录/未登录显隐。 */
|
|
1010
|
+
function closeResetForm() {
|
|
1011
|
+
$("auth-reset-form").classList.add("hidden");
|
|
1012
|
+
renderAuthState();
|
|
1013
|
+
switchAuthTab("login");
|
|
1014
|
+
refreshVisibleCaptchas();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** 密码重置成功后,服务端已吊销全部旧会话/授权:清同源残留的反馈线程凭据(threads)。 */
|
|
1018
|
+
function clearThreadsAfterReset() {
|
|
1019
|
+
try { localStorage.removeItem("dsh-feedback-threads"); } catch {}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function doPasswordReset() {
|
|
1023
|
+
const phone = $("reset-phone").value.trim();
|
|
1024
|
+
const sms = $("reset-sms-code").value.trim();
|
|
1025
|
+
const pass = $("reset-pass").value;
|
|
1026
|
+
const msg = $("reset-msg");
|
|
1027
|
+
if (!validPhone(phone)) { msg.className = "auth-msg err"; msg.textContent = "请输入正确的手机号"; return; }
|
|
1028
|
+
if (!sms) { msg.className = "auth-msg err"; msg.textContent = "请输入短信验证码"; return; }
|
|
1029
|
+
if (pass.length < 8) { msg.className = "auth-msg err"; msg.textContent = "新密码至少 8 位"; return; }
|
|
1030
|
+
msg.className = "auth-msg"; msg.innerHTML = '<span class="loading"></span> 重置中...';
|
|
1031
|
+
const res = await api("/api/password/reset", { phone, sms_code: sms, new_password: pass });
|
|
1032
|
+
if (res.status === 200 && res.data && res.data.ok === true) {
|
|
1033
|
+
// 成功:本地 token/cookie/threads 全清(旧会话已由服务端吊销),提示用新密码登录
|
|
1034
|
+
clearThreadsAfterReset();
|
|
1035
|
+
logout(true);
|
|
1036
|
+
const lm = $("login-msg");
|
|
1037
|
+
lm.className = "auth-msg ok";
|
|
1038
|
+
lm.textContent = "✅ 密码已重置,请用新密码登录。";
|
|
1039
|
+
toast("密码已重置,旧设备/会话已全部失效", 4000);
|
|
1040
|
+
} else {
|
|
1041
|
+
msg.className = "auth-msg err";
|
|
1042
|
+
msg.textContent = res.data?.error?.message || `重置失败(${res.status})`;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** 统一退出:清 localStorage + dsh_token/dsh_device Cookie,并注销服务端 JWT。
|
|
1047
|
+
* 同时清空 E2EE 内存会话与参数缓存(密钥随登录态离开;绝不留驻)。 */
|
|
1048
|
+
function logout(clearForm = false) {
|
|
1049
|
+
const token = state.token;
|
|
1050
|
+
state.token = ""; state.user = null;
|
|
1051
|
+
localStorage.removeItem("dsh_token"); localStorage.removeItem("dsh_user");
|
|
1052
|
+
document.cookie = "dsh_token=; path=/; max-age=0; SameSite=Lax; Secure";
|
|
1053
|
+
document.cookie = "dsh_device=; path=/; max-age=0; SameSite=Lax; Secure";
|
|
1054
|
+
if (token) fetch(API_BASE + "/api/logout", { method: "POST", headers: { authorization: `Bearer ${token}` } }).catch(() => {});
|
|
1055
|
+
// E2EE:清空内存会话/参数(协议 §5.1:密钥只存内存,退出即失效)
|
|
1056
|
+
e2eePendingPassword = null; // 内存中的登录密码随退出即失效
|
|
1057
|
+
e2eeMkClear(); e2eeStoredMk = null; // 本机记住的解锁材料(MK)随退出清除
|
|
1058
|
+
try { if (typeof e2eeClient !== "undefined") { e2eeClient.sessions.clear(); e2eeClient.params = null; e2eeClient.paramsT = 0; } } catch {}
|
|
1059
|
+
try { if (typeof e2eeUpdateBadge === "function") e2eeUpdateBadge(); } catch {}
|
|
1060
|
+
try { if (typeof e2eeCloseUnlock === "function") e2eeCloseUnlock(); } catch {}
|
|
1061
|
+
// Phase-4:登出同时清除一次性交接单与「记住本机」持久交接单(镜像页不得残留可复用会话材料)
|
|
1062
|
+
try { if (typeof dshE2eeHandoverClear === "function") dshE2eeHandoverClear(sessionStorage); } catch {}
|
|
1063
|
+
try { if (typeof dshE2eeHandoverClear === "function") dshE2eeHandoverClear(localStorage); } catch {}
|
|
1064
|
+
setConn("未连接");
|
|
1065
|
+
if (clearForm) {
|
|
1066
|
+
["login-phone", "login-pass", "login-captcha", "login-sms-code", "login-sms-captcha", "reg-phone", "reg-sms-code", "reg-sms-captcha", "reg-pass", "reg-pass2", "reset-phone", "reset-captcha", "reset-sms-code", "reset-pass"].forEach(id => { const el = $(id); if (el) el.value = ""; });
|
|
1067
|
+
switchAuthTab("login");
|
|
1068
|
+
}
|
|
1069
|
+
// 忘记密码小表单:登出/会话失效后一律收起,回到常规登录卡
|
|
1070
|
+
const resetForm = $("auth-reset-form");
|
|
1071
|
+
if (resetForm) resetForm.classList.add("hidden");
|
|
1072
|
+
renderAuthState();
|
|
1073
|
+
setView("login");
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// ---------- 一次性扫码登录(/app/?auth=KEY:电脑端生成的访问链接,30 分钟/一次性) ----------
|
|
1077
|
+
|
|
1078
|
+
/** UA 关键词简判,随一次性登录请求上报 os/browser(服务端仅作统计,无需精确识别)。
|
|
1079
|
+
* os 按 iPhone/iPad/Mac/Android/Windows/Linux 关键词粗判;browser 按
|
|
1080
|
+
* WeChat/MiuiBrowser/Quark/Edge/Chrome/Firefox/Safari 判;取不到统一兜底「手机浏览器」。 */
|
|
1081
|
+
function detectClientUA() {
|
|
1082
|
+
const ua = navigator.userAgent || "";
|
|
1083
|
+
let os = "";
|
|
1084
|
+
if (/iPhone|iPad/.test(ua)) os = "iOS";
|
|
1085
|
+
else if (/Android/.test(ua)) os = "Android";
|
|
1086
|
+
else if (/Mac/.test(ua)) os = "macOS";
|
|
1087
|
+
else if (/Windows/.test(ua)) os = "Windows";
|
|
1088
|
+
else if (/Linux/.test(ua)) os = "Linux";
|
|
1089
|
+
else os = "其他";
|
|
1090
|
+
let browser = "";
|
|
1091
|
+
if (/MicroMessenger/i.test(ua)) browser = "WeChat";
|
|
1092
|
+
else if (/MiuiBrowser/i.test(ua)) browser = "MiuiBrowser";
|
|
1093
|
+
else if (/Quark/i.test(ua)) browser = "Quark";
|
|
1094
|
+
else if (/Edg\//i.test(ua)) browser = "Edge";
|
|
1095
|
+
else if (/Chrome\//i.test(ua)) browser = "Chrome";
|
|
1096
|
+
else if (/Firefox\//i.test(ua)) browser = "Firefox";
|
|
1097
|
+
else if (/Safari\//i.test(ua)) browser = "Safari";
|
|
1098
|
+
else browser = "手机浏览器";
|
|
1099
|
+
return { os, browser };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** 链接失效/失败落地:清登录态 → 登录页常驻展示可读错误(不让页面停在无解释状态)。 */
|
|
1103
|
+
function authFailToLogin(message) {
|
|
1104
|
+
logout();
|
|
1105
|
+
switchAuthTab("login");
|
|
1106
|
+
const m = $("login-msg");
|
|
1107
|
+
if (m) { m.className = "auth-msg err"; m.textContent = message; }
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* 一次性访问链接登录(boot 阶段 location.search 含 auth=KEY 时统一调用,按路由分派):
|
|
1112
|
+
* 无论当前是否已登录,有 auth 就走 exchange(幂等;已用/过期/无效的 key 会被服务端拒绝)。
|
|
1113
|
+
* 成功 → token/user 走与正常登录完全相同的落库路径(localStorage dsh_token/dsh_user
|
|
1114
|
+
* + dsh_token cookie 由 enterMirror 统一写入)→ 进入设备列表视图;
|
|
1115
|
+
* 失败 → 业务性拒绝(4xx 且带 error)视为链接失效:登出并提示重新扫码/生成;
|
|
1116
|
+
* 网络/超时/5xx 视为暂时不可用:保留原有有效会话(若有),否则留在登录页展示可读错误。
|
|
1117
|
+
* 无论成败:处理完立即用 history.replaceState 隐去地址栏 auth 明文(保留 utm 等其它参数)。
|
|
1118
|
+
*/
|
|
1119
|
+
async function doAuthExchangeLogin(key) {
|
|
1120
|
+
// 1) 先隐去地址栏明文 key:key 只在内存中使用,后续任何分支/刷新都不会再残留
|
|
1121
|
+
try {
|
|
1122
|
+
history.replaceState(null, "", location.pathname + location.search.replace(/([?&])auth=[^&#]*/, "$1").replace(/[?&]$/, ""));
|
|
1123
|
+
} catch { /* 个别 WebView 限制 replaceState:忽略,不阻断登录流程 */ }
|
|
1124
|
+
const boot = $("boot-status-text");
|
|
1125
|
+
if (boot) boot.innerHTML = '<span class="loading"></span> 正在验证一次性登录链接…';
|
|
1126
|
+
|
|
1127
|
+
// 2) 调用企业端 exchange(公开端点无需登录;os/browser 由 detectClientUA 上报;与 /relay-api 其余请求一致的 6s 超时)
|
|
1128
|
+
let status = 0, data = {}, errMsg = "";
|
|
1129
|
+
try {
|
|
1130
|
+
const ua = detectClientUA();
|
|
1131
|
+
const r = await fetch(API_BASE + "/api/auth-key/exchange", {
|
|
1132
|
+
method: "POST",
|
|
1133
|
+
headers: { "content-type": "application/json" },
|
|
1134
|
+
body: JSON.stringify({ key, os: ua.os, browser: ua.browser }),
|
|
1135
|
+
signal: AbortSignal.timeout(6000)
|
|
1136
|
+
});
|
|
1137
|
+
status = r.status;
|
|
1138
|
+
data = await r.json().catch(() => ({}));
|
|
1139
|
+
} catch (e) { errMsg = e.name === "TimeoutError" ? "请求超时" : e.message; }
|
|
1140
|
+
|
|
1141
|
+
// 3) 成功:token/user 落库(localStorage + 同 enterMirror 的 dsh_token cookie 语义)
|
|
1142
|
+
if (status === 200 && data && data.token) {
|
|
1143
|
+
state.token = data.token;
|
|
1144
|
+
// 字段容错:user 缺失/非对象时保留原 user,再不行置空,不阻断登录
|
|
1145
|
+
if (data.user && typeof data.user === "object") state.user = data.user;
|
|
1146
|
+
else if (!state.user) state.user = null;
|
|
1147
|
+
localStorage.setItem("dsh_token", state.token);
|
|
1148
|
+
localStorage.setItem("dsh_user", JSON.stringify(state.user));
|
|
1149
|
+
enterMirror(state.token); // 写 dsh_token cookie → 设备列表(0/多台均由用户点选进入)
|
|
1150
|
+
if (!state.user) setConn("已连接", true);
|
|
1151
|
+
toast("✅ 扫码登录成功,可点选在线设备进入", 3500);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// 4) 失败分类:业务性拒绝(服务端明确返回 error 的 4xx,含 invalid_auth_key/已用/过期/无效)
|
|
1156
|
+
const err = data && data.error || null;
|
|
1157
|
+
const serverMsg = err && typeof err.message === "string" ? err.message : "";
|
|
1158
|
+
const code = err && err.code ? String(err.code) : "";
|
|
1159
|
+
if (status >= 400 && status < 500 && err) {
|
|
1160
|
+
const invalidKey = code === "invalid_auth_key" || !serverMsg || /失效|无效|已用|过期|invalid|expired|used/i.test(serverMsg);
|
|
1161
|
+
const text = invalidKey
|
|
1162
|
+
? "链接已失效,请返回电脑端重新生成/登录。"
|
|
1163
|
+
: "链接验证失败: " + serverMsg;
|
|
1164
|
+
authFailToLogin(text);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
// 网络/超时/服务端异常:错误可读,不静默。仍持有有效会话 → 保留登录并提示;否则常驻可读错误
|
|
1168
|
+
const why = errMsg
|
|
1169
|
+
? "无法连接服务器: " + errMsg + ",请检查网络后重试。"
|
|
1170
|
+
: (serverMsg || (status ? "服务异常(HTTP " + status + "),请稍后重试。" : "无法连接服务器,请检查网络后重试。"));
|
|
1171
|
+
if (state.token && !tokenExpired(state.token)) {
|
|
1172
|
+
toast("扫码登录未完成(" + why.replace(/[。.]+$/, "") + ")已沿用当前登录", 5000);
|
|
1173
|
+
enterMirror(state.token);
|
|
1174
|
+
} else {
|
|
1175
|
+
authFailToLogin("扫码登录失败: " + why);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// ---------- 新用户安装引导(空设备状态;后台 public-config 可覆盖) ----------
|
|
1180
|
+
|
|
1181
|
+
/** 返回安装引导 HTML:后台配置 app_install_guide 非空时优先展示,否则用内置默认步骤。 */
|
|
1182
|
+
function installGuideHTML() {
|
|
1183
|
+
const custom = pubConfig && pubConfig.app_install_guide;
|
|
1184
|
+
if (custom) return custom;
|
|
1185
|
+
const cmdInstall = "npx @mrrisega/dsh-remote";
|
|
1186
|
+
return `
|
|
1187
|
+
<div class="guide">
|
|
1188
|
+
<div class="guide-title">💻 还差最后一步:在电脑上装一下</div>
|
|
1189
|
+
<div class="guide-sub">本页会自动检测——电脑装好并登录后,设备会自己出现在这里,<b>不需要刷新页面</b>。</div>
|
|
1190
|
+
<ol>
|
|
1191
|
+
<li><b>在你要控制的电脑上打开 DeepSeek Harness</b>(就是平时写代码的那个窗口)。</li>
|
|
1192
|
+
</ol>
|
|
1193
|
+
<div class="guide-path">
|
|
1194
|
+
<div class="guide-path-tag">如果你有「插件市场」入口 · 不用敲命令</div>
|
|
1195
|
+
<ol>
|
|
1196
|
+
<li>在 Harness 里打开<b>插件市场 / 插件中心</b>(不同版本位置不同,通常在设置或侧栏)。</li>
|
|
1197
|
+
<li>搜索 <code>dsh-remote</code> → 点<b>安装</b> — 只装一个面板插件,运行环境与开机自启由它自动补齐。</li>
|
|
1198
|
+
<li>按面板提示<b>重启一次 DeepSeek Harness</b>。</li>
|
|
1199
|
+
</ol>
|
|
1200
|
+
</div>
|
|
1201
|
+
<div class="guide-path">
|
|
1202
|
+
<div class="guide-path-tag">没有市场入口?用这条命令 · 一条搞定</div>
|
|
1203
|
+
<ol>
|
|
1204
|
+
<li>在电脑上打开「终端 / Terminal」,把下面这条命令<b>整行复制粘贴</b>进去回车:
|
|
1205
|
+
<div class="guide-cmd"><code>${esc(cmdInstall)}</code><button type="button" class="guide-copy" data-guide-copy="${esc(cmdInstall)}">复制</button></div>
|
|
1206
|
+
</li>
|
|
1207
|
+
</ol>
|
|
1208
|
+
</div>
|
|
1209
|
+
<div class="guide-path">
|
|
1210
|
+
<div class="guide-path-tag">两条路走完后都一样</div>
|
|
1211
|
+
<ol>
|
|
1212
|
+
<li>在电脑上打开「设置 → 📱 远程访问」,用<b>本页注册的手机号</b>登录。</li>
|
|
1213
|
+
<li>回到本页稍等几秒,电脑会自己出现在列表里(不用刷新)。</li>
|
|
1214
|
+
</ol>
|
|
1215
|
+
</div>
|
|
1216
|
+
<details class="guide-more">
|
|
1217
|
+
<summary>安装报错 / 想手动控制?展开看细节</summary>
|
|
1218
|
+
<ol>
|
|
1219
|
+
<li><b>提示找不到 node</b> — 先在电脑上装 Node.js 22+(从 <code>nodejs.org</code> 下载),终端运行 <code>node -v</code> 确认 ≥ v22 后,再重跑上面那条命令。</li>
|
|
1220
|
+
<li><b>想看/改配置</b> — 运行 <code>npx @mrrisega/dsh-remote settings</code> 打开设置页,登录账号、启停 bridge、切换自建服务都在这里。</li>
|
|
1221
|
+
<li><b>服务要常驻</b> — 命令安装会自动创建开机自启(launchd / systemd);插件市场安装时由插件自动补齐。</li>
|
|
1222
|
+
</ol>
|
|
1223
|
+
</details>
|
|
1224
|
+
</div>`;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* 上报「用户看到了空设备列表 + 安装引导」。
|
|
1229
|
+
* 这是漏斗里最关键的中间态:生产诊断显示 11 个新用户里 6 人从未在电脑端安装,而此前没有任何数据
|
|
1230
|
+
* 能证明"他看到了引导"。服务端只记首次(guide_shown_at);这里再加 localStorage 去重,避免重复请求。
|
|
1231
|
+
* 失败完全静默:纯统计,绝不影响用户流程。
|
|
1232
|
+
*/
|
|
1233
|
+
function reportGuideShown() {
|
|
1234
|
+
try {
|
|
1235
|
+
if (!state.token) return; // 未登录不报(服务端要求登录态)
|
|
1236
|
+
if (localStorage.getItem("dsh-guide-shown") === "1") return; // 本机只报一次
|
|
1237
|
+
localStorage.setItem("dsh-guide-shown", "1");
|
|
1238
|
+
api("/api/guide-shown", {}, "POST").catch(() => {});
|
|
1239
|
+
} catch (e) { /* 忽略 */ }
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// ---------- 空设备态自动等待(无需用户手动刷新) ----------
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* 设备轮询:手机端停留在这页时,若一台在线设备都没有,就自动轻量探测(见 probeOnlineDevices),
|
|
1246
|
+
* 一旦电脑上线立即自动切到设备列表——用户不需要点「刷新设备列表」。
|
|
1247
|
+
* 首次 10 次每 5s(等首装最急的那一分钟),之后退到 20s;页面不可见时暂停,回到前台立即探测。
|
|
1248
|
+
*/
|
|
1249
|
+
let devicePollTimer = null;
|
|
1250
|
+
let devicePollTicks = 0;
|
|
1251
|
+
function cancelDevicePoll() {
|
|
1252
|
+
if (devicePollTimer) { clearTimeout(devicePollTimer); devicePollTimer = null; }
|
|
1253
|
+
}
|
|
1254
|
+
function scheduleDevicePoll() {
|
|
1255
|
+
cancelDevicePoll();
|
|
1256
|
+
if (lastOnlineCount > 0) return; // 已有在线设备 → 停止轮询
|
|
1257
|
+
if (document.hidden) return; // 页面不可见 → 暂停(visibilitychange 恢复)
|
|
1258
|
+
if ($("view-devices").classList.contains("hidden")) return; // 已离开设备页
|
|
1259
|
+
const wait = devicePollTicks < 10 ? 5000 : 20000;
|
|
1260
|
+
devicePollTimer = setTimeout(() => {
|
|
1261
|
+
devicePollTimer = null;
|
|
1262
|
+
devicePollTicks += 1;
|
|
1263
|
+
void probeOnlineDevices();
|
|
1264
|
+
}, wait);
|
|
1265
|
+
}
|
|
1266
|
+
function resetDevicePoll() { cancelDevicePoll(); devicePollTicks = 0; }
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* 轻量探测:只问 Router 的实时在线设备(带 dsh_token cookie,不写账号库),
|
|
1270
|
+
* 因此可以放心高频轮询,不会污染 /api/devices 的活跃统计。
|
|
1271
|
+
* 一旦发现设备(数量变化或首次出现)→ 走一次完整刷新,渲染出可点选的设备列表。
|
|
1272
|
+
*/
|
|
1273
|
+
async function probeOnlineDevices() {
|
|
1274
|
+
try {
|
|
1275
|
+
const r = await fetch("/_devices", { cache: "no-store" });
|
|
1276
|
+
if (r.status === 401) { await refreshDevices(); return; } // 登录态失效:交给完整刷新路径(会提示重新登录)
|
|
1277
|
+
const d = await r.json().catch(() => ({}));
|
|
1278
|
+
const ids = (Array.isArray(d.devices) ? d.devices : []).filter(x => x && x.id).map(x => String(x.id));
|
|
1279
|
+
if (ids.length > 0 && ids.length !== lastOnlineCount) {
|
|
1280
|
+
devicePollTicks = 0;
|
|
1281
|
+
await refreshDevices();
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
} catch { /* 网络抖动:下一轮再试 */ }
|
|
1285
|
+
scheduleDevicePoll();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// 回到前台 / 切到设备页时立即探测一次(不用等下一个轮询周期)
|
|
1289
|
+
document.addEventListener("visibilitychange", () => {
|
|
1290
|
+
if (document.hidden) { cancelDevicePoll(); return; }
|
|
1291
|
+
if (!$("view-devices").classList.contains("hidden")) {
|
|
1292
|
+
if (lastOnlineCount > 0) void refreshDevices(); else void probeOnlineDevices();
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
/** 为引导卡片内的「复制」按钮绑定命令复制。 */
|
|
1297
|
+
function bindGuideCopy(root) {
|
|
1298
|
+
root.querySelectorAll("[data-guide-copy]").forEach(btn => {
|
|
1299
|
+
btn.onclick = async () => {
|
|
1300
|
+
const text = btn.dataset.guideCopy || "";
|
|
1301
|
+
try { await navigator.clipboard.writeText(text); toast("安装命令已复制"); }
|
|
1302
|
+
catch { toast(text, 6000); }
|
|
1303
|
+
};
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// ---------- 设备列表(合并:Router 实时在线 /_devices + 账号已绑定设备 /relay-api/api/devices) ----------
|
|
1308
|
+
|
|
1309
|
+
/** 拉取账号已绑定设备(GET API_BASE + "/api/devices" = /relay-api/api/devices,Bearer token)。
|
|
1310
|
+
* 失败抛错,由 refreshDevices 统一优雅降级。
|
|
1311
|
+
* 容错:后端字段版本差异 → 名称可能叫 name 或 device_name;在线标记缺失/不一致时,最终以 Router 实时列表交集判定。 */
|
|
1312
|
+
async function fetchAccountDevices() {
|
|
1313
|
+
const r = await fetch(API_BASE + "/api/devices", {
|
|
1314
|
+
method: "GET",
|
|
1315
|
+
headers: state.token ? { authorization: `Bearer ${state.token}` } : {},
|
|
1316
|
+
signal: AbortSignal.timeout(6000)
|
|
1317
|
+
});
|
|
1318
|
+
const d = await r.json().catch(() => ({}));
|
|
1319
|
+
if (r.status !== 200) throw new Error("http " + r.status);
|
|
1320
|
+
return (Array.isArray(d.devices) ? d.devices : [])
|
|
1321
|
+
.filter(x => x && x.id)
|
|
1322
|
+
.map(x => ({
|
|
1323
|
+
id: String(x.id),
|
|
1324
|
+
name: String(x.device_name || x.name || x.id),
|
|
1325
|
+
online: x.online === true || x.online === 1
|
|
1326
|
+
}));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** 单台设备行 HTML:在线 → 绿色徽标,可点进入;离线 → 置灰并标注「离线 · 电脑端未连接」。
|
|
1330
|
+
* Router /_devices 已透传 caps(Phase-2 §6.2):含 e2ee-v2 且在线 → 显示 🔒 可加密。
|
|
1331
|
+
* data-caps/data-name 供点选时的 E2EE 门控(e2eeMaybeGateThenEnter)读取。 */
|
|
1332
|
+
function deviceRowHTML(d) {
|
|
1333
|
+
const caps = Array.isArray(d.caps) ? d.caps : [];
|
|
1334
|
+
if (d.online) {
|
|
1335
|
+
const capChip = caps.includes("e2ee-v2")
|
|
1336
|
+
? `<span class="e2ee-chip" title="电脑端声明支持端到端加密">🔒 可加密</span>` : "";
|
|
1337
|
+
return `
|
|
1338
|
+
<div class="device" data-device="${esc(d.id)}" data-name="${esc(d.name || d.id)}" data-caps="${esc(caps.join(","))}">
|
|
1339
|
+
<div class="icon">🟢</div>
|
|
1340
|
+
<div>
|
|
1341
|
+
<div class="name">${esc(d.name || d.id)}${capChip}</div>
|
|
1342
|
+
<div class="meta">${esc(d.id)}</div>
|
|
1343
|
+
</div>
|
|
1344
|
+
<div class="status on">在线</div>
|
|
1345
|
+
</div>`;
|
|
1346
|
+
}
|
|
1347
|
+
return `
|
|
1348
|
+
<div class="device off" data-device="${esc(d.id)}">
|
|
1349
|
+
<div class="icon">💻</div>
|
|
1350
|
+
<div>
|
|
1351
|
+
<div class="name">${esc(d.name || d.id)}</div>
|
|
1352
|
+
<div class="meta">${esc(d.id)} · 离线 · 电脑端未连接</div>
|
|
1353
|
+
</div>
|
|
1354
|
+
</div>`;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** 列表底部轻提示(离线引导 / 降级说明),样式见 .list-hint。 */
|
|
1358
|
+
function listHintHTML(html) { return `<div class="list-hint">${html}</div>`; }
|
|
1359
|
+
|
|
1360
|
+
async function refreshDevices() {
|
|
1361
|
+
const list = $("device-list");
|
|
1362
|
+
list.innerHTML = '<div class="empty">加载中...</div>';
|
|
1363
|
+
$("signal-label").textContent = "正在查询设备...";
|
|
1364
|
+
$("dot-signal").classList.remove("on");
|
|
1365
|
+
|
|
1366
|
+
// 1) Router 实时在线设备(权威:决定当前能否点选进入,带 dsh_token cookie)
|
|
1367
|
+
let routerOk = false, routerErr = "", routerDevices = [];
|
|
1368
|
+
try {
|
|
1369
|
+
const r = await fetch("/_devices", { cache: "no-store" });
|
|
1370
|
+
if (r.status === 401) {
|
|
1371
|
+
logout();
|
|
1372
|
+
toast("登录已过期,请重新登录");
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const d = await r.json().catch(() => ({}));
|
|
1376
|
+
routerDevices = (Array.isArray(d.devices) ? d.devices : [])
|
|
1377
|
+
.filter(x => x && x.id)
|
|
1378
|
+
.map(x => ({ id: String(x.id), name: String(x.name || x.id), caps: Array.isArray(x.caps) ? x.caps.map(String) : [] }));
|
|
1379
|
+
routerOk = true;
|
|
1380
|
+
} catch (e) { routerErr = e.message; }
|
|
1381
|
+
|
|
1382
|
+
// 2) 账号已绑定设备(/relay-api,Bearer)。失败/未登录/非账号体系部署(自建)→ 优雅降级,仅用 Router 在线列表
|
|
1383
|
+
let boundOk = false, boundDevices = [];
|
|
1384
|
+
if (deploymentMode !== "self" && state.token) {
|
|
1385
|
+
try {
|
|
1386
|
+
boundDevices = await fetchAccountDevices();
|
|
1387
|
+
boundOk = true;
|
|
1388
|
+
} catch { /* 降级:只用 Router 在线列表,不报错不打断 */ }
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// 3) 合并渲染:已绑定设备铺底 + Router 实时在线设备并入(并集,不漏可进入设备);
|
|
1392
|
+
// 能否进入以 Router 实时连接为准 → 在线标记 = 是否出现在 Router /_devices 列表。
|
|
1393
|
+
const byId = new Map();
|
|
1394
|
+
for (const b of boundDevices) byId.set(b.id, { id: b.id, name: b.name || b.id, online: false, caps: [] });
|
|
1395
|
+
for (const rd of routerDevices) {
|
|
1396
|
+
const ex = byId.get(rd.id);
|
|
1397
|
+
if (ex) { ex.online = true; if (ex.name === ex.id) ex.name = rd.name; ex.caps = rd.caps; }
|
|
1398
|
+
else byId.set(rd.id, { id: rd.id, name: rd.name || rd.id, online: true, caps: rd.caps });
|
|
1399
|
+
}
|
|
1400
|
+
const all = [...byId.values()];
|
|
1401
|
+
const online = all.filter(d => d.online);
|
|
1402
|
+
const offline = all.filter(d => !d.online);
|
|
1403
|
+
|
|
1404
|
+
// Router 查询失败:已绑定设备仍照常列出(均视为离线不可点),否则维持原错误提示
|
|
1405
|
+
if (!routerOk) {
|
|
1406
|
+
if (all.length > 0) {
|
|
1407
|
+
$("signal-label").textContent = `已绑定 ${all.length} 台设备`;
|
|
1408
|
+
list.innerHTML = all.map(deviceRowHTML).join("") +
|
|
1409
|
+
listHintHTML(`⚠️ 暂时无法确认实时在线状态${routerErr ? `(${esc(routerErr)})` : ""}。以下为账号已绑定设备,请检查网络后点「刷新设备列表」重试。`);
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
lastOnlineCount = 0;
|
|
1413
|
+
list.innerHTML = `<div class="empty">获取在线设备失败:${esc(routerErr)}。<br>会自动重试,也可以点"刷新"。</div>`;
|
|
1414
|
+
scheduleDevicePoll();
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// 一个已绑定/在线设备都没有 → 展示安装引导 + 自动等待(不再是空白/无解释,也不需要手动刷新)
|
|
1419
|
+
if (all.length === 0) {
|
|
1420
|
+
lastOnlineCount = 0;
|
|
1421
|
+
$("signal-label").textContent = "等待电脑上线…";
|
|
1422
|
+
const emptyText = boundOk ? "还没有绑定任何设备" : "暂无在线设备";
|
|
1423
|
+
list.innerHTML = `<div class="empty" style="padding:12px 0 8px;">${emptyText}</div>` +
|
|
1424
|
+
`<div class="empty guide-wait" style="padding:0 0 8px;"><span class="loading"></span> 正在自动检测:电脑装好后会自己出现在这里,无需刷新本页</div>` +
|
|
1425
|
+
installGuideHTML();
|
|
1426
|
+
bindGuideCopy(list);
|
|
1427
|
+
reportGuideShown(); // 盲区补齐:记录"看到了引导",用于量化「看到引导→真的装上」的转化
|
|
1428
|
+
scheduleDevicePoll();
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// 有设备:在线可点进入,离线置灰 + 底部一行引导
|
|
1433
|
+
list.innerHTML = [...online, ...offline].map(deviceRowHTML).join("");
|
|
1434
|
+
if (offline.length > 0) {
|
|
1435
|
+
list.insertAdjacentHTML("beforeend", listHintHTML(
|
|
1436
|
+
`💡 有 ${offline.length} 台设备离线(电脑端未连接)。请在电脑上打开 dsh web → 设置 → 远程控制,确保 bridge 运行中,然后点「刷新设备列表」。`
|
|
1437
|
+
));
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
lastOnlineCount = online.length;
|
|
1441
|
+
$("signal-label").textContent = boundOk
|
|
1442
|
+
? `设备 ${all.length} 台${online.length ? ` · ${online.length} 台在线` : " · 当前均离线"}`
|
|
1443
|
+
: (online.length ? `在线设备 ${online.length} 台` : "暂无在线设备");
|
|
1444
|
+
$("dot-signal").classList.toggle("on", online.length > 0);
|
|
1445
|
+
scheduleDevicePoll(); // 全部离线时继续自动等上线;有在线设备则自动停止
|
|
1446
|
+
|
|
1447
|
+
// 行点击:在线 → E2EE 门控后进入(未启用/无能力 → 原明文流程,零行为变化);离线 → 轻提示引导
|
|
1448
|
+
list.querySelectorAll(".device").forEach(el => {
|
|
1449
|
+
el.onclick = () => {
|
|
1450
|
+
if (el.classList.contains("off")) toast("该设备当前无法进入:请在电脑上确认 bridge 运行中后,点「刷新设备列表」重试", 3500);
|
|
1451
|
+
else {
|
|
1452
|
+
const caps = (el.dataset.caps || "").split(",").filter(Boolean);
|
|
1453
|
+
void e2eeMaybeGateThenEnter(el.dataset.device, el.dataset.name || el.dataset.device, caps);
|
|
1454
|
+
}
|
|
1455
|
+
};
|
|
1456
|
+
});
|
|
1457
|
+
// 进入设备列表时顺带拉取一次账号级 e2ee 参数并刷新徽标(失败静默)
|
|
1458
|
+
void e2eeGetParams(false).then(e2eeUpdateBadge).catch(() => {});
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/* ============================================================
|
|
1462
|
+
* E2EE 手机端客户端(Phase-3)——浏览器 WebCrypto 实现
|
|
1463
|
+
*
|
|
1464
|
+
* 依据:
|
|
1465
|
+
* docs/e2ee-protocol.md §3/§4/§5/§9 与 clients/dsh-remote/e2ee-client.mjs
|
|
1466
|
+
* 文件头「规范化决定 1–6」(桥端 Phase-2 实现,已由 e2ee-bridge.test.mjs 验证)。
|
|
1467
|
+
* 本段 = 手机端与桥端必须**逐字节一致**的部分;测试通过「从本文件抽取
|
|
1468
|
+
* ==DSH-E2EE-WC-CORE== 区间 → 在 node 里与 e2ee-client.mjs 对拍」保证。
|
|
1469
|
+
*
|
|
1470
|
+
* 规范化对照(与桥端 e2ee-client.mjs 相同,不得各自发挥):
|
|
1471
|
+
* 1. 域/info 分隔符 \x00;info 形如 "dsh-e2ee/v1\0<用途>"。
|
|
1472
|
+
* 2. SHK = HKDF-SHA256(ikm=MK, salt=sha256(a‖b), info="dsh-e2ee/v1\0shk", 32)
|
|
1473
|
+
* 子密钥 = HKDF-SHA256(ikm=SHK, salt=sha256(a‖b), info=域串, 32)
|
|
1474
|
+
* (WebCrypto deriveBits 与 node hkdfSync 同算法,输出一致。)
|
|
1475
|
+
* 3. 子密钥 info:
|
|
1476
|
+
* http 请求 "dsh-e2ee/v1\0http\0<reqNonceB64>\0p2b" (reqNonce=请求信封 n)
|
|
1477
|
+
* http 响应 "…\0http\0<reqNonceB64>\0b2p" (响应信封 n 回显请求 n)
|
|
1478
|
+
* ws 流 "…\0ws\0<wsLabel>\0p2b|b2p"
|
|
1479
|
+
* ctrl 探针 "…\0ctrl\0p2b|b2p"
|
|
1480
|
+
* 4. AAD = UTF8("dsh-e2ee/v1\0<kind>\0<sessId>\0<dir>\0<counter>")。
|
|
1481
|
+
* 5. 信封 {v:2,k,s,c,n,t,d}:n=base64url(12B nonce),d=base64url(AES-256-GCM 密文‖16B tag)。
|
|
1482
|
+
* 6. ctrl 通道(/_e2ee/ctrl):hello/hello-ack/e2ee-error 为明文 JSON;probe 为 k:"ctrl" AEAD 信封。
|
|
1483
|
+
*
|
|
1484
|
+
* WebCrypto 与 node:crypto 差异说明(唯一语义差异处,均已对齐):
|
|
1485
|
+
* - PBKDF2/HKDF/AES-GCM 在 subtle 中全部为异步;同步路径(如直接 fetch 组装)改 async。
|
|
1486
|
+
* - importKey 需要明确用途;deriveBits 返回 ArrayBuffer(以 Uint8Array 承接,32B)。
|
|
1487
|
+
* - 无 sync randomBytes → crypto.getRandomValues(同强度)。
|
|
1488
|
+
* - 无 Buffer → Uint8Array;base64url 编解码用 atob/btoa 自实现(wcBytesToB64/wcB64ToBytes)。
|
|
1489
|
+
* - gzip 解压用 DecompressionStream('gzip')(async,现代浏览器/Node 均可用)。
|
|
1490
|
+
*
|
|
1491
|
+
* 密钥纪律(协议 §5.1/§8 风险 5):MK/SHK/子密钥只存本页内存(e2eeClient.sessions/
|
|
1492
|
+
* WcE2eeSession 字段),绝不写 localStorage/sessionStorage/IndexedDB;刷新/重开需重新解锁。
|
|
1493
|
+
* ============================================================ */
|
|
1494
|
+
|
|
1495
|
+
/* ===DSH-E2EE-WC-CORE-START=== (纯原语区间:无 DOM 依赖,node 抽取对拍用) */
|
|
1496
|
+
const WC_E2EE_DOMAIN = "dsh-e2ee/v1";
|
|
1497
|
+
const WC_MK_LABEL = "dsh-e2ee/v1/mk\x00";
|
|
1498
|
+
const WC_SALT_BYTES = 16;
|
|
1499
|
+
const WC_MK_BYTES = 32;
|
|
1500
|
+
const WC_NONCE_BYTES = 12;
|
|
1501
|
+
const WC_TAG_BYTES = 16;
|
|
1502
|
+
const WC_SESS_ID_RE = /^[0-9a-f]{32}$/i;
|
|
1503
|
+
const WC_E2EE_VERSION = 2;
|
|
1504
|
+
const WC_ENVELOPE_CONTENT_TYPE = "application/vnd.dsh.e2ee-v2";
|
|
1505
|
+
const WC_ENVELOPE_HEADER = "x-dsh-e2ee";
|
|
1506
|
+
const WC_E2EE_CAP = "e2ee-v2";
|
|
1507
|
+
const WC_DIR_P2B = "p2b";
|
|
1508
|
+
const WC_DIR_B2P = "b2p";
|
|
1509
|
+
|
|
1510
|
+
function wcNormalizePassword(pw) { return String(pw ?? "").normalize("NFKC"); }
|
|
1511
|
+
function wcUtf8(s) { return new TextEncoder().encode(String(s ?? "")); }
|
|
1512
|
+
function wcUtf8Decode(u8) { return new TextDecoder().decode(u8); }
|
|
1513
|
+
|
|
1514
|
+
/** base64url(无 padding)解码 → Uint8Array;非法抛错。 */
|
|
1515
|
+
function wcB64ToBytes(b64) {
|
|
1516
|
+
if (typeof b64 !== "string" || b64 === "") throw new Error("e2ee: base64url 非法");
|
|
1517
|
+
let s = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
1518
|
+
while (s.length % 4) s += "=";
|
|
1519
|
+
const bin = atob(s);
|
|
1520
|
+
const out = new Uint8Array(bin.length);
|
|
1521
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
1522
|
+
return out;
|
|
1523
|
+
}
|
|
1524
|
+
function wcBytesToB64(bytes) {
|
|
1525
|
+
const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
1526
|
+
let bin = "";
|
|
1527
|
+
const CH = 0x8000;
|
|
1528
|
+
for (let i = 0; i < u.length; i += CH) bin += String.fromCharCode.apply(null, u.subarray(i, i + CH));
|
|
1529
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1530
|
+
}
|
|
1531
|
+
function wcConcat(...parts) {
|
|
1532
|
+
let total = 0;
|
|
1533
|
+
for (const p of parts) total += p.length;
|
|
1534
|
+
const out = new Uint8Array(total);
|
|
1535
|
+
let o = 0;
|
|
1536
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
1537
|
+
return out;
|
|
1538
|
+
}
|
|
1539
|
+
function wcRandomBytes(n) {
|
|
1540
|
+
const u = new Uint8Array(n);
|
|
1541
|
+
crypto.getRandomValues(u);
|
|
1542
|
+
return u;
|
|
1543
|
+
}
|
|
1544
|
+
function wcRandomB64(n) { return wcBytesToB64(wcRandomBytes(n)); }
|
|
1545
|
+
function wcRandomHex(n) {
|
|
1546
|
+
let s = "";
|
|
1547
|
+
const u = wcRandomBytes(n);
|
|
1548
|
+
for (let i = 0; i < u.length; i++) s += u[i].toString(16).padStart(2, "0");
|
|
1549
|
+
return s;
|
|
1550
|
+
}
|
|
1551
|
+
function wcNewSessId() { return wcRandomHex(16); } // 128-bit hex,32 字符
|
|
1552
|
+
async function wcSha256Bytes(...parts) {
|
|
1553
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", wcConcat(...parts)));
|
|
1554
|
+
}
|
|
1555
|
+
/** decodeSalt:16B base64url → Uint8Array;非法返回 null(协议 §3.1)。 */
|
|
1556
|
+
function wcDecodeSalt(saltB64) {
|
|
1557
|
+
if (typeof saltB64 !== "string" || saltB64 === "") return null;
|
|
1558
|
+
let b;
|
|
1559
|
+
try { b = wcB64ToBytes(saltB64); } catch { return null; }
|
|
1560
|
+
return b.length === WC_SALT_BYTES ? b : null;
|
|
1561
|
+
}
|
|
1562
|
+
/** sha256|sha384|sha512 → WebCrypto 标准 "SHA-256" 风格名(subtle 只认连字符形式)。 */
|
|
1563
|
+
function wcHashName(hash) {
|
|
1564
|
+
const m = /^sha(256|384|512)$/i.exec(String(hash || ""));
|
|
1565
|
+
return m ? "SHA-" + m[1] : "SHA-256";
|
|
1566
|
+
}
|
|
1567
|
+
/** 容错归一 kdf(兼容 iter/iterations 两种下发写法;与 node normalizeKdf 同构)。 */
|
|
1568
|
+
function wcNormalizeKdf(kdf) {
|
|
1569
|
+
const D = { alg: "pbkdf2-sha256", iter: 600000, dkLen: WC_MK_BYTES, hash: "sha256" };
|
|
1570
|
+
if (!kdf || typeof kdf !== "object") return D;
|
|
1571
|
+
const iter = Number(kdf.iter ?? kdf.iterations);
|
|
1572
|
+
const dkLen = Number(kdf.dkLen ?? kdf.keyLen ?? kdf.length ?? WC_MK_BYTES);
|
|
1573
|
+
const hash = typeof kdf.hash === "string" && kdf.hash ? kdf.hash : "sha256";
|
|
1574
|
+
if (!Number.isInteger(iter) || iter < 1) throw new Error("e2ee: kdf.iter 非法");
|
|
1575
|
+
if (!Number.isInteger(dkLen) || dkLen < 1 || dkLen > 64) throw new Error("e2ee: kdf.dkLen 非法");
|
|
1576
|
+
if (!/^sha(256|384|512)$/.test(hash)) throw new Error("e2ee: kdf.hash 不受支持: " + hash);
|
|
1577
|
+
return { alg: typeof kdf.alg === "string" && kdf.alg ? kdf.alg : "pbkdf2-sha256", iter, dkLen, hash };
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* 派生客户端主密钥 MK(协议 §3.3;node 侧见 deriveMasterKey):
|
|
1581
|
+
* kdfInput = UTF8("dsh-e2ee/v1/mk\0") ‖ rawSalt(16B)
|
|
1582
|
+
* MK(32B) = PBKDF2-HMAC-SHA256(NFKC(password), salt=kdfInput, iter, dkLen)
|
|
1583
|
+
* 参数按服务端下发执行,绝不写死。
|
|
1584
|
+
* @returns {Promise<Uint8Array>} 32B
|
|
1585
|
+
*/
|
|
1586
|
+
async function wcDeriveMasterKey(password, saltB64, kdf) {
|
|
1587
|
+
const salt = wcDecodeSalt(saltB64);
|
|
1588
|
+
if (!salt) throw new Error("e2ee: salt 非法(须为 16B base64url)");
|
|
1589
|
+
const { iter, dkLen, hash } = wcNormalizeKdf(kdf);
|
|
1590
|
+
const kdfInput = wcConcat(wcUtf8(WC_MK_LABEL), salt);
|
|
1591
|
+
const key = await crypto.subtle.importKey("raw", wcUtf8(wcNormalizePassword(password)), "PBKDF2", false, ["deriveBits"]);
|
|
1592
|
+
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: wcHashName(hash), salt: kdfInput, iterations: iter }, key, dkLen * 8);
|
|
1593
|
+
return new Uint8Array(bits);
|
|
1594
|
+
}
|
|
1595
|
+
/** HKDF-SHA256(info 为字符串,内部 UTF8;与 node hkdfSync(ikm,salt,info,len) 同构)。 */
|
|
1596
|
+
async function wcHkdf(ikm, salt, infoStr, len) {
|
|
1597
|
+
const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
|
|
1598
|
+
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: wcUtf8(infoStr) }, key, len * 8);
|
|
1599
|
+
return new Uint8Array(bits);
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* 由 MK 与握手随机数 a/b 派生 SHK(文件头决定 2;node 侧 deriveShk):
|
|
1603
|
+
* saltH = sha256(a‖b)(32B); SHK = HKDF-SHA256(ikm=MK, salt=saltH, info="dsh-e2ee/v1\0shk", 32)
|
|
1604
|
+
* @returns {Promise<{shk:Uint8Array, saltH:Uint8Array}>}
|
|
1605
|
+
*/
|
|
1606
|
+
async function wcDeriveShk(mkBytes, aB64, bB64) {
|
|
1607
|
+
if (!mkBytes || mkBytes.length !== WC_MK_BYTES) throw new Error("e2ee: MK 非法");
|
|
1608
|
+
const a = wcB64ToBytes(aB64);
|
|
1609
|
+
const b = wcB64ToBytes(bB64);
|
|
1610
|
+
if (a.length !== WC_MK_BYTES || b.length !== WC_MK_BYTES) throw new Error("e2ee: 握手随机数须为 32B");
|
|
1611
|
+
const saltH = await wcSha256Bytes(a, b);
|
|
1612
|
+
const shk = await wcHkdf(mkBytes, saltH, `${WC_E2EE_DOMAIN}\x00shk`, WC_MK_BYTES);
|
|
1613
|
+
return { shk, saltH };
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** 会话级错误(带稳定 code;不得携带密钥材料)。 */
|
|
1617
|
+
function wcErr(code, message) {
|
|
1618
|
+
const e = new Error(message || code);
|
|
1619
|
+
e.code = code;
|
|
1620
|
+
e.wcE2ee = true;
|
|
1621
|
+
return e;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* 一个解锁会话(浏览器版 E2eeSession;密钥仅内存)。
|
|
1626
|
+
* 提供子密钥派生 + 信封加解密原语;不持有密码/MK 明文以外的任何落盘材料。
|
|
1627
|
+
*/
|
|
1628
|
+
class WcE2eeSession {
|
|
1629
|
+
constructor({ sessId, shk, saltH, profile = "", epoch = 0 }) {
|
|
1630
|
+
if (typeof sessId !== "string" || !WC_SESS_ID_RE.test(sessId)) throw wcErr("bad_session", "e2ee: sessId 非法");
|
|
1631
|
+
if (!shk || shk.length !== WC_MK_BYTES) throw wcErr("bad_session", "e2ee: SHK 非法");
|
|
1632
|
+
if (!saltH || saltH.length !== WC_MK_BYTES) throw wcErr("bad_session", "e2ee: saltH 非法");
|
|
1633
|
+
this.sessId = sessId;
|
|
1634
|
+
this.shk = shk;
|
|
1635
|
+
this.saltH = saltH;
|
|
1636
|
+
this.profile = profile;
|
|
1637
|
+
this.epoch = epoch;
|
|
1638
|
+
this.verified = false;
|
|
1639
|
+
}
|
|
1640
|
+
/** 子密钥(文件头决定 3);kind∈http|http-resp|w|ctrl。 */
|
|
1641
|
+
async keyBytesFor({ kind, dir, reqNonceB64 = "", wsLabel = "" }) {
|
|
1642
|
+
let use;
|
|
1643
|
+
if (kind === "http" || kind === "http-resp") {
|
|
1644
|
+
if (!reqNonceB64) throw wcErr("bad_keyinfo", "e2ee: http 子密钥需要 reqNonceB64");
|
|
1645
|
+
use = `http\x00${reqNonceB64}\x00${dir}`;
|
|
1646
|
+
} else if (kind === "w") {
|
|
1647
|
+
if (!wsLabel) throw wcErr("bad_keyinfo", "e2ee: ws 子密钥需要 wsLabel");
|
|
1648
|
+
use = `ws\x00${wsLabel}\x00${dir}`;
|
|
1649
|
+
} else if (kind === "ctrl") {
|
|
1650
|
+
use = `ctrl\x00${dir}`;
|
|
1651
|
+
} else {
|
|
1652
|
+
throw wcErr("bad_keyinfo", "e2ee: 未知信封 kind: " + kind);
|
|
1653
|
+
}
|
|
1654
|
+
if (dir !== WC_DIR_P2B && dir !== WC_DIR_B2P) throw wcErr("bad_keyinfo", "e2ee: 未知方向: " + dir);
|
|
1655
|
+
return wcHkdf(this.shk, this.saltH, `${WC_E2EE_DOMAIN}\x00${use}`, WC_MK_BYTES);
|
|
1656
|
+
}
|
|
1657
|
+
/** AAD(文件头决定 4)。 */
|
|
1658
|
+
aadBytesOf({ kind, dir, counter }) {
|
|
1659
|
+
return wcUtf8(`${WC_E2EE_DOMAIN}\x00${kind}\x00${this.sessId}\x00${dir}\x00${String(counter ?? 0)}`);
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* 密封一条消息 → 信封对象(node 侧 seal 的浏览器异步版)。
|
|
1663
|
+
* @returns {Promise<{v:number,k:string,s:string,c:number,n:string,t:number,d:string}>}
|
|
1664
|
+
*/
|
|
1665
|
+
async seal({ kind, dir, counter = 0, data, t, reqNonceB64 = "", wsLabel = "", nonceBytes }) {
|
|
1666
|
+
const isText = typeof data === "string";
|
|
1667
|
+
const pt = isText ? wcUtf8(data) : (data instanceof Uint8Array ? data : new Uint8Array(data));
|
|
1668
|
+
const tOut = t !== undefined ? (t === 0 ? 0 : 1) : kind === "w" ? (isText ? 0 : 1) : 0;
|
|
1669
|
+
const n = nonceBytes || wcRandomBytes(WC_NONCE_BYTES);
|
|
1670
|
+
if (!n || n.length !== WC_NONCE_BYTES) throw wcErr("bad_nonce", "e2ee: nonce 须为 12B");
|
|
1671
|
+
// 规范(决定 3):http 请求信封 key-info reqNonce 默认 = 本信封 n(自描述);
|
|
1672
|
+
// http-resp 的 key-info reqNonce 必须为对应请求 n(由调用方显式传入)。
|
|
1673
|
+
const keyNonce = kind === "http-resp" ? reqNonceB64 : reqNonceB64 || wcBytesToB64(n);
|
|
1674
|
+
const keyBytes = await this.keyBytesFor({ kind, dir, reqNonceB64: keyNonce, wsLabel });
|
|
1675
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
|
|
1676
|
+
const enc = await crypto.subtle.encrypt(
|
|
1677
|
+
{ name: "AES-GCM", iv: n, additionalData: this.aadBytesOf({ kind, dir, counter }), tagLength: 128 },
|
|
1678
|
+
key,
|
|
1679
|
+
pt
|
|
1680
|
+
);
|
|
1681
|
+
return {
|
|
1682
|
+
v: WC_E2EE_VERSION,
|
|
1683
|
+
k: kind,
|
|
1684
|
+
s: this.sessId,
|
|
1685
|
+
c: counter,
|
|
1686
|
+
n: wcBytesToB64(n),
|
|
1687
|
+
t: tOut,
|
|
1688
|
+
d: wcBytesToB64(new Uint8Array(enc)) // AES-GCM 输出 = 密文‖tag
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* 打开(解密+认证)一条消息。认证失败/字段不符抛 code=… 的 Error。
|
|
1693
|
+
* @returns {Promise<{data:Uint8Array, t:number}>}
|
|
1694
|
+
*/
|
|
1695
|
+
async open({ kind, dir, env, counter, reqNonceB64 = "", wsLabel = "" }) {
|
|
1696
|
+
if (!env || typeof env !== "object") throw wcErr("bad_envelope", "e2ee: 信封缺失");
|
|
1697
|
+
if (env.v !== WC_E2EE_VERSION) throw wcErr("bad_version", "e2ee: 信封版本不受支持: " + env.v);
|
|
1698
|
+
if (env.k !== kind) throw wcErr("bad_kind", "e2ee: 信封 kind 不符");
|
|
1699
|
+
if (env.s !== this.sessId) throw wcErr("bad_session", "e2ee: 信封会话不符");
|
|
1700
|
+
let raw;
|
|
1701
|
+
try { raw = wcB64ToBytes(env.d); } catch { raw = null; }
|
|
1702
|
+
if (!raw || raw.length < WC_TAG_BYTES + 1) throw wcErr("bad_cipher", "e2ee: 密文非法");
|
|
1703
|
+
let n;
|
|
1704
|
+
try { n = wcB64ToBytes(env.n); } catch { n = null; }
|
|
1705
|
+
if (!n || n.length !== WC_NONCE_BYTES) throw wcErr("bad_nonce", "e2ee: nonce 非法");
|
|
1706
|
+
const keyNonce = kind === "http-resp" ? reqNonceB64 : reqNonceB64 || String(env.n || "");
|
|
1707
|
+
const keyBytes = await this.keyBytesFor({ kind, dir, reqNonceB64: keyNonce, wsLabel });
|
|
1708
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["decrypt"]);
|
|
1709
|
+
let out;
|
|
1710
|
+
try {
|
|
1711
|
+
out = new Uint8Array(await crypto.subtle.decrypt(
|
|
1712
|
+
{ name: "AES-GCM", iv: n, additionalData: this.aadBytesOf({ kind, dir, counter }), tagLength: 128 },
|
|
1713
|
+
key,
|
|
1714
|
+
raw
|
|
1715
|
+
));
|
|
1716
|
+
} catch {
|
|
1717
|
+
throw wcErr("auth_failed", "e2ee: AEAD 打开失败(密文被篡改或密钥不一致)");
|
|
1718
|
+
}
|
|
1719
|
+
return { data: out, t: env.t === 1 ? 1 : 0 };
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// ---------- http/ws 明文载荷编解码(node 侧 decode/encodeHttp*Plain 的浏览器版) ----------
|
|
1724
|
+
|
|
1725
|
+
function wcParseJsonBytes(u8) {
|
|
1726
|
+
try { return JSON.parse(u8 instanceof Uint8Array ? wcUtf8Decode(u8) : String(u8)); }
|
|
1727
|
+
catch { throw wcErr("bad_plain", "e2ee: 明文不是 JSON"); }
|
|
1728
|
+
}
|
|
1729
|
+
/** 请求信封明文 {m,p,h,b}(§4.3)。 */
|
|
1730
|
+
function wcDecodeHttpRequestPlain(buf) {
|
|
1731
|
+
const pt = wcParseJsonBytes(buf);
|
|
1732
|
+
if (!pt || typeof pt !== "object") throw wcErr("bad_plain", "e2ee: http 请求明文缺失");
|
|
1733
|
+
const method = typeof pt.m === "string" && pt.m ? pt.m.toUpperCase() : "GET";
|
|
1734
|
+
const path = typeof pt.p === "string" && pt.p ? pt.p : "/";
|
|
1735
|
+
const headers = pt.h && typeof pt.h === "object" ? pt.h : {};
|
|
1736
|
+
const b = typeof pt.b === "string" ? pt.b : "";
|
|
1737
|
+
return { method, path, headers, bodyB64: b, hasBody: b !== "" };
|
|
1738
|
+
}
|
|
1739
|
+
function wcEncodeHttpRequestPlain({ method = "GET", path = "/", headers = {}, bodyB64 = "" } = {}) {
|
|
1740
|
+
return wcUtf8(JSON.stringify({ m: String(method).toUpperCase(), p: path, h: headers, b: String(bodyB64 ?? "") }));
|
|
1741
|
+
}
|
|
1742
|
+
/** 响应信封明文 {st,h,enc,b}(§4.3)。 */
|
|
1743
|
+
function wcDecodeHttpResponsePlain(buf) {
|
|
1744
|
+
const pt = wcParseJsonBytes(buf);
|
|
1745
|
+
if (!pt || typeof pt !== "object") throw wcErr("bad_plain", "e2ee: http 响应明文缺失");
|
|
1746
|
+
return {
|
|
1747
|
+
status: Number(pt.st) || 502,
|
|
1748
|
+
headers: pt.h && typeof pt.h === "object" ? pt.h : {},
|
|
1749
|
+
enc: typeof pt.enc === "string" ? pt.enc : "",
|
|
1750
|
+
bodyB64: typeof pt.b === "string" ? pt.b : ""
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
function wcEncodeHttpResponsePlain({ status = 200, headers = {}, bodyB64 = "", enc } = {}) {
|
|
1754
|
+
const h = {};
|
|
1755
|
+
let encOut = enc || "";
|
|
1756
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
1757
|
+
const lk = String(k).toLowerCase();
|
|
1758
|
+
if (["content-length", "content-encoding", "transfer-encoding", "connection", "keep-alive"].includes(lk)) {
|
|
1759
|
+
if (lk === "content-encoding" && !enc) encOut = String(v);
|
|
1760
|
+
continue;
|
|
1761
|
+
}
|
|
1762
|
+
h[k] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
1763
|
+
}
|
|
1764
|
+
return wcUtf8(JSON.stringify({ st: Number(status) || 200, h, enc: encOut, b: String(bodyB64 ?? "") }));
|
|
1765
|
+
}
|
|
1766
|
+
/** enc=gzip 时解压响应正文(async)。 */
|
|
1767
|
+
async function wcGunzip(u8) {
|
|
1768
|
+
try {
|
|
1769
|
+
const ds = new DecompressionStream("gzip");
|
|
1770
|
+
const stream = new Blob([u8]).stream().pipeThrough(ds);
|
|
1771
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
1772
|
+
} catch { return null; }
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* 解析数据 WS 打开路径的 e2ee 标记(node 侧 parseWsE2eeParams 同构):
|
|
1777
|
+
* upstreamPath: 去掉 e2ee 与 w 两参后的干净上游路径;
|
|
1778
|
+
* wsLabel: 仅去掉 e2ee 的重组 query(path+query,内含 w → 每流唯一)。
|
|
1779
|
+
*/
|
|
1780
|
+
function wcParseWsE2eeParams(fullPath) {
|
|
1781
|
+
if (typeof fullPath !== "string" || !fullPath.startsWith("/")) return null;
|
|
1782
|
+
const qIdx = fullPath.indexOf("?");
|
|
1783
|
+
const path = qIdx === -1 ? fullPath : fullPath.slice(0, qIdx);
|
|
1784
|
+
if (qIdx === -1) return null;
|
|
1785
|
+
const params = new URLSearchParams(fullPath.slice(qIdx + 1));
|
|
1786
|
+
const sessId = params.get("e2ee") || "";
|
|
1787
|
+
const w = params.get("w") || "";
|
|
1788
|
+
if (!sessId || !w || !WC_SESS_ID_RE.test(sessId)) return null;
|
|
1789
|
+
params.delete("e2ee");
|
|
1790
|
+
const qsLabel = params.size ? `?${params.toString()}` : "";
|
|
1791
|
+
const labelWithW = `${path}${qsLabel}`; // w 保留其中
|
|
1792
|
+
params.delete("w");
|
|
1793
|
+
const qsUp = params.size ? `?${params.toString()}` : "";
|
|
1794
|
+
return { sessId, w, wsLabel: labelWithW, upstreamPath: `${path}${qsUp}` };
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/** 信封标记头(§4.3 头即信号;node 侧 envelopeRequestHeaders)。 */
|
|
1798
|
+
function wcEnvelopeHeaders(sessId, kind = "http") {
|
|
1799
|
+
return { "content-type": WC_ENVELOPE_CONTENT_TYPE, [WC_ENVELOPE_HEADER]: `v=${WC_E2EE_VERSION};s=${sessId};k=${kind}` };
|
|
1800
|
+
}
|
|
1801
|
+
function wcHeaderValueOf(headers, name) {
|
|
1802
|
+
const lk = String(name).toLowerCase();
|
|
1803
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
1804
|
+
if (String(k).toLowerCase() === lk) return Array.isArray(v) ? v.join(", ") : String(v);
|
|
1805
|
+
}
|
|
1806
|
+
return "";
|
|
1807
|
+
}
|
|
1808
|
+
function wcParseEnvelopeMarker(headers = {}) {
|
|
1809
|
+
const val = wcHeaderValueOf(headers, WC_ENVELOPE_HEADER);
|
|
1810
|
+
if (!val) return null;
|
|
1811
|
+
const m = /^v=(\d+);s=([0-9a-f]{32});k=([a-z-]+)$/i.exec(val.trim());
|
|
1812
|
+
return m ? { v: Number(m[1]), s: m[2], k: m[3] } : null;
|
|
1813
|
+
}
|
|
1814
|
+
function wcHasEnvelopeMarker(headers = {}) {
|
|
1815
|
+
const ct = wcHeaderValueOf(headers, "content-type").split(";")[0].trim().toLowerCase();
|
|
1816
|
+
if (ct === WC_ENVELOPE_CONTENT_TYPE) return true;
|
|
1817
|
+
return wcParseEnvelopeMarker(headers) !== null;
|
|
1818
|
+
}
|
|
1819
|
+
/* ===DSH-E2EE-WC-CORE-END=== */
|
|
1820
|
+
|
|
1821
|
+
/* ===DSH-E2EE-HANDOVER-CORE-START=== */
|
|
1822
|
+
// Phase-4 一次性交接单(键 dsh-e2ee-handover;镜像页读后即删)。取舍:只放「已探针互认的
|
|
1823
|
+
// 会话材料」{v,s,shk,salt,profile,epoch}(sessId+SHK+saltH 32B b64url),绝不放 MK/密码;
|
|
1824
|
+
// 镜像页(side e2ee-shim-script.js)用同 sessId 复用桥端已验证会话(24h TTL)。
|
|
1825
|
+
var DSH_E2EE_HANDOVER_KEY = "dsh-e2ee-handover";
|
|
1826
|
+
function dshE2eeHandoverEncode(sess) {
|
|
1827
|
+
if (!sess || typeof sess.sessId !== "string" || !/^[0-9a-f]{32}$/i.test(sess.sessId) || !sess.shk || sess.shk.length !== 32 || !sess.saltH || sess.saltH.length !== 32) return null;
|
|
1828
|
+
return JSON.stringify({ v: 2, s: sess.sessId, shk: wcBytesToB64(sess.shk), salt: wcBytesToB64(sess.saltH), profile: String(sess.profile || ""), epoch: Number(sess.epoch) || 0 });
|
|
1829
|
+
}
|
|
1830
|
+
function dshE2eeHandoverWrite(sess, storage) { try { const raw = dshE2eeHandoverEncode(sess); if (raw) storage.setItem(DSH_E2EE_HANDOVER_KEY, raw); } catch (e) {} }
|
|
1831
|
+
function dshE2eeHandoverClear(storage) { try { storage.removeItem(DSH_E2EE_HANDOVER_KEY); } catch (e) {} }
|
|
1832
|
+
/* ===DSH-E2EE-HANDOVER-CORE-END=== */
|
|
1833
|
+
/** enterDevice 前置(Phase-4):解锁会话→写一次性交接单;明文跳过/未解锁→清(防串台)。 */
|
|
1834
|
+
function dshE2eeHandoverSync(deviceId) {
|
|
1835
|
+
try {
|
|
1836
|
+
if (typeof e2eeClient === "undefined") {
|
|
1837
|
+
dshE2eeHandoverClear(sessionStorage);
|
|
1838
|
+
try { dshE2eeHandoverClear(localStorage); } catch (e) {}
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
const ent = e2eeClient.sessions.get(deviceId) || null;
|
|
1842
|
+
if (ent && ent.sess) {
|
|
1843
|
+
dshE2eeHandoverWrite(ent.sess, sessionStorage);
|
|
1844
|
+
// 记住本机(2026-09):同一交接单同时写 localStorage —— 刷新镜像页后可自动恢复加密
|
|
1845
|
+
// (登出时清除;与 localStorage 中的派生 MK 同策略)。
|
|
1846
|
+
try { dshE2eeHandoverWrite(ent.sess, localStorage); } catch (e) {}
|
|
1847
|
+
} else {
|
|
1848
|
+
dshE2eeHandoverClear(sessionStorage);
|
|
1849
|
+
try { dshE2eeHandoverClear(localStorage); } catch (e) {}
|
|
1850
|
+
}
|
|
1851
|
+
} catch (e) {}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// ---------- E2EE 客户端状态(仅内存;密钥不落 localStorage) ----------
|
|
1855
|
+
const E2EE_ERR_TEXT = {
|
|
1856
|
+
bad_key: "电脑端保存的账号密码与本次输入不一致。请在电脑端面板重新登录该账号后重试。",
|
|
1857
|
+
auth_failed: "解密校验失败:密码与电脑端不一致或传输被篡改,请重试。",
|
|
1858
|
+
params: "加密参数与电脑端不一致(密码或服务端参数可能已更换),请重新登录账号后重试。",
|
|
1859
|
+
disabled: "电脑端当前未启用端到端加密,已按明文路径继续。",
|
|
1860
|
+
e2ee_disabled: "电脑端未启用端到端加密(请检查账号密码/服务端开关),已按明文路径继续。",
|
|
1861
|
+
no_cap: "电脑端版本过旧或不支持端到端加密,已按明文路径继续。",
|
|
1862
|
+
busy: "电脑端正忙,请稍后重试。",
|
|
1863
|
+
unknown_session: "会话不存在或已过期,请重新解锁。",
|
|
1864
|
+
not_verified: "会话尚未完成探针校验,请重新解锁。",
|
|
1865
|
+
replay: "检测到重放,请重新解锁。",
|
|
1866
|
+
ws_closed: "与电脑端的加密握手连接提前关闭,请检查网络后重试。",
|
|
1867
|
+
ws_timeout: "加密握手超时,请检查网络后重试。",
|
|
1868
|
+
derive_failed: "密钥派生失败,请确认密码后重试。"
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
// 自动解锁(2026-09,免二次输密码):登录/注册时用户刚输入过密码,在本页内存保留一份,
|
|
1872
|
+
// 点选 E2EE 设备时静默解锁。发送给服务端?否。短信/扫码登录无密码 → 自动引导或解锁层。
|
|
1873
|
+
let e2eePendingPassword = null;
|
|
1874
|
+
|
|
1875
|
+
// 解锁材料持久化(2026-09 用户决策「刷新后仍保持加密」):解锁成功后把派生 MK 存入
|
|
1876
|
+
// localStorage(与镜像页同源共用),刷新/重开 /app 后点设备仍自动加密进入,不再要求重输密码。
|
|
1877
|
+
// 注意(如实告知):localStorage 与同源页面(含被控的镜像 dsh web)脚本等价可读,此选项≈
|
|
1878
|
+
// 「记住本机密码」;退出登录即清除。协议 §5.1 [DECISION-2026-09] 已按产品决策更新该边界。
|
|
1879
|
+
var E2EE_MK_STORE_KEY = "dsh-e2ee-mk-v1";
|
|
1880
|
+
function e2eeMkLoad() {
|
|
1881
|
+
try { var s = localStorage.getItem(E2EE_MK_STORE_KEY); return s && s.length >= 32 ? s : null; } catch (e) { return null; }
|
|
1882
|
+
}
|
|
1883
|
+
function e2eeMkSave(mkB64) { try { if (mkB64) localStorage.setItem(E2EE_MK_STORE_KEY, String(mkB64)); } catch (e) {} }
|
|
1884
|
+
function e2eeMkClear() { try { localStorage.removeItem(E2EE_MK_STORE_KEY); } catch (e) {} }
|
|
1885
|
+
let e2eeStoredMk = e2eeMkLoad(); // 启动即读:刷新/重开后自动解锁可用
|
|
1886
|
+
|
|
1887
|
+
const e2eeClient = {
|
|
1888
|
+
params: null, // {enabled, reason, profile, salt, kdf, epoch, status}
|
|
1889
|
+
paramsT: 0,
|
|
1890
|
+
sessions: new Map(), // deviceId → { params, sess: WcE2eeSession, unlockedAt }(仅内存)
|
|
1891
|
+
deviceName: new Map() // deviceId → name(解锁层展示)
|
|
1892
|
+
};
|
|
1893
|
+
|
|
1894
|
+
/** 拉取并容错解析 GET {apiBase}/api/e2ee-params(Bearer;协议 §3.4/§6.1)。
|
|
1895
|
+
* 任何失败 → {enabled:false, reason}(明文回退,不抛)。缓存 30s,force 时重取。 */
|
|
1896
|
+
async function e2eeGetParams(force = false) {
|
|
1897
|
+
const cached = e2eeClient.params;
|
|
1898
|
+
if (cached && !force && Date.now() - e2eeClient.paramsT < 30000) return cached;
|
|
1899
|
+
const miss = (reason, status = 0) => {
|
|
1900
|
+
if (cached && !force) return cached;
|
|
1901
|
+
return { enabled: false, reason, status };
|
|
1902
|
+
};
|
|
1903
|
+
if (!state.token || deploymentMode === "self") return miss("no_token");
|
|
1904
|
+
let r;
|
|
1905
|
+
try {
|
|
1906
|
+
r = await fetch(API_BASE + "/api/e2ee-params", {
|
|
1907
|
+
headers: { authorization: `Bearer ${state.token}`, accept: "application/json" },
|
|
1908
|
+
signal: AbortSignal.timeout(8000)
|
|
1909
|
+
});
|
|
1910
|
+
} catch { return miss("params_unreachable"); }
|
|
1911
|
+
if (r.status !== 200) return miss(r.status === 401 ? "server_unauthorized" : r.status === 404 ? "server_no_params" : "http_" + r.status, r.status);
|
|
1912
|
+
let data;
|
|
1913
|
+
try { data = await r.json(); } catch { return miss("bad_params", 200); }
|
|
1914
|
+
const e2 = data && typeof data === "object" ? data.e2ee : null;
|
|
1915
|
+
if (!e2 || typeof e2 !== "object") return miss("server_no_params", 200);
|
|
1916
|
+
if (e2.enabled !== true) return miss("server_disabled", 200);
|
|
1917
|
+
if (typeof e2.salt !== "string" || !wcDecodeSalt(e2.salt)) return miss("bad_params", 200);
|
|
1918
|
+
let kdf;
|
|
1919
|
+
try { kdf = wcNormalizeKdf(e2.kdf); } catch { return miss("bad_params", 200); }
|
|
1920
|
+
const p = {
|
|
1921
|
+
enabled: true, reason: "ok", status: 200,
|
|
1922
|
+
profile: typeof e2.profile === "string" && e2.profile ? e2.profile : "pbkdf2-sha256-600k",
|
|
1923
|
+
salt: e2.salt,
|
|
1924
|
+
epoch: Number(e2.epoch) || 0,
|
|
1925
|
+
kdf
|
|
1926
|
+
};
|
|
1927
|
+
e2eeClient.params = p;
|
|
1928
|
+
e2eeClient.paramsT = Date.now();
|
|
1929
|
+
return p;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/** 设备是否具备 E2EE 能力(账号 enabled ∧ 设备声明 e2ee-v2 ∧ 非自建)。 */
|
|
1933
|
+
function e2eeCapable(params, caps) {
|
|
1934
|
+
return !!(params && params.enabled === true && Array.isArray(caps) && caps.includes(WC_E2EE_CAP));
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/** WebCrypto 可用性(subtle 需要安全上下文 https/wss;不可用 → 明文回退 + 提示)。 */
|
|
1938
|
+
function e2eeSubtleAvailable() {
|
|
1939
|
+
return !!(globalThis.crypto && globalThis.crypto.subtle && globalThis.crypto.getRandomValues);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/** 控制 WS 地址:device 形态 {origin}/remote/<deviceId>/_e2ee/ctrl(§5.2)。 */
|
|
1943
|
+
function e2eeCtrlWsUrl(deviceId) {
|
|
1944
|
+
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
1945
|
+
return `${proto}//${window.location.host}/remote/${encodeURIComponent(deviceId)}/_e2ee/ctrl`;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* §5.2 手机侧全握手:hello → hello-ack → deriveShk → 探针(ctrl AEAD)→ probe-ok。
|
|
1950
|
+
* @param {string} deviceId
|
|
1951
|
+
* @param {object} o { password?, params, mkBytes? }
|
|
1952
|
+
* password=账号密码时现场派生 MK;mkBytes(32B,方案A 桌面授权引导下发)=直接用,免输密码。
|
|
1953
|
+
* @returns {Promise<{sessId:string, sess:WcE2eeSession}>} verified 后返回
|
|
1954
|
+
*/
|
|
1955
|
+
function e2eeUnlockHandshake(deviceId, { password, params, mkBytes }) {
|
|
1956
|
+
return new Promise((resolve, reject) => {
|
|
1957
|
+
const sessId = wcNewSessId();
|
|
1958
|
+
const aB64 = wcRandomB64(32);
|
|
1959
|
+
if (mkBytes && mkBytes.length !== WC_MK_BYTES) {
|
|
1960
|
+
reject(wcErr("derive_failed", E2EE_ERR_TEXT.derive_failed));
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
let ws;
|
|
1964
|
+
let timer = null;
|
|
1965
|
+
let settled = false;
|
|
1966
|
+
const done = (v) => { if (settled) return; settled = true; clearTimeout(timer); try { ws.close(1000); } catch {} resolve(v); };
|
|
1967
|
+
const fail = (e) => { if (settled) return; settled = true; clearTimeout(timer); try { ws.close(); } catch {} reject(e); };
|
|
1968
|
+
try {
|
|
1969
|
+
ws = new WebSocket(e2eeCtrlWsUrl(deviceId));
|
|
1970
|
+
} catch (e) { fail(wcErr("ws_closed", E2EE_ERR_TEXT.ws_closed)); return; }
|
|
1971
|
+
timer = setTimeout(() => fail(wcErr("ws_timeout", E2EE_ERR_TEXT.ws_timeout)), 20000);
|
|
1972
|
+
ws.onerror = () => { /* onclose 统一收尾 */ };
|
|
1973
|
+
ws.onclose = (ev) => {
|
|
1974
|
+
if (!settled) fail(wcErr("ws_closed", E2EE_ERR_TEXT.ws_closed + (ev.reason ? `(${ev.reason})` : "")));
|
|
1975
|
+
};
|
|
1976
|
+
ws.onopen = () => {
|
|
1977
|
+
ws.send(JSON.stringify({
|
|
1978
|
+
v: 2, type: "e2ee-hello", role: "phone", s: sessId,
|
|
1979
|
+
a: aB64, salt: params.salt, profile: params.profile, ts: Date.now()
|
|
1980
|
+
}));
|
|
1981
|
+
};
|
|
1982
|
+
let session = null;
|
|
1983
|
+
ws.onmessage = async (ev) => {
|
|
1984
|
+
let msg;
|
|
1985
|
+
try { msg = JSON.parse(String(ev.data)); } catch { return; }
|
|
1986
|
+
if (!msg || typeof msg !== "object") return;
|
|
1987
|
+
if (msg.type === "e2ee-error") {
|
|
1988
|
+
const code = String(msg.code || "e2ee_failed");
|
|
1989
|
+
const text = E2EE_ERR_TEXT[code] || (msg.message ? String(msg.message) : ("电脑端握手失败(" + code + ")"));
|
|
1990
|
+
fail(wcErr(code, text));
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
if (msg.type === "e2ee-hello-ack") {
|
|
1994
|
+
try {
|
|
1995
|
+
const mk = mkBytes || await wcDeriveMasterKey(password, params.salt, params.kdf);
|
|
1996
|
+
const { shk, saltH } = await wcDeriveShk(mk, aB64, String(msg.b || ""));
|
|
1997
|
+
session = new WcE2eeSession({ sessId, shk, saltH, profile: params.profile, epoch: params.epoch });
|
|
1998
|
+
const probe = await session.seal({
|
|
1999
|
+
kind: "ctrl", dir: WC_DIR_P2B, counter: 0,
|
|
2000
|
+
data: JSON.stringify({ p: "dsh-e2ee-probe-v1", t: Date.now(), c: 0 })
|
|
2001
|
+
});
|
|
2002
|
+
ws.send(JSON.stringify(probe));
|
|
2003
|
+
} catch (e) {
|
|
2004
|
+
fail(e && e.wcE2ee ? e : wcErr("derive_failed", E2EE_ERR_TEXT.derive_failed));
|
|
2005
|
+
}
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
if (session && msg.v === 2 && msg.k === "ctrl") {
|
|
2009
|
+
// probe-ok:开包通过 = 双方 MK 一致;会话 verified
|
|
2010
|
+
try {
|
|
2011
|
+
const opened = await session.open({ kind: "ctrl", dir: WC_DIR_B2P, env: msg, counter: msg.c ?? 0 });
|
|
2012
|
+
const pt = JSON.parse(wcUtf8Decode(opened.data));
|
|
2013
|
+
if (pt && pt.p === "dsh-e2ee-probe-ok") {
|
|
2014
|
+
session.verified = true;
|
|
2015
|
+
done({ sessId, sess: session });
|
|
2016
|
+
} else {
|
|
2017
|
+
fail(wcErr("bad_key", E2EE_ERR_TEXT.bad_key));
|
|
2018
|
+
}
|
|
2019
|
+
} catch (e) {
|
|
2020
|
+
fail(e && e.wcE2ee ? e : wcErr("bad_key", E2EE_ERR_TEXT.bad_key));
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
};
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/** 解锁/引导成功后的统一收尾:会话仅存内存 + 刷新徽标。 */
|
|
2028
|
+
function e2eeCommitSession(deviceId, params, r) {
|
|
2029
|
+
e2eeClient.sessions.set(deviceId, { params, sess: r.sess, unlockedAt: Date.now() });
|
|
2030
|
+
e2eeClient.deviceName.set(deviceId, e2eeClient.deviceName.get(deviceId) || deviceId);
|
|
2031
|
+
e2eeUpdateBadge();
|
|
2032
|
+
return { ok: true, code: "ok", sessId: r.sessId };
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
/** 解锁设备:密码 → 派生 MK → ctrl 握手(探针通过即「双方 MK 一致」)。会话仅存内存;
|
|
2036
|
+
* 成功后派生 MK 按用户决策写入 localStorage(刷新后自动解锁用,退出登录清除)。 */
|
|
2037
|
+
async function e2eeUnlockDevice(deviceId, password) {
|
|
2038
|
+
if (!e2eeSubtleAvailable()) return { ok: false, code: "no_webcrypto", text: "当前浏览器不支持端到端加密所需的安全环境,请用系统浏览器(HTTPS)重试;已按明文路径继续。" };
|
|
2039
|
+
const params = await e2eeGetParams(true);
|
|
2040
|
+
if (!params.enabled) return { ok: false, code: params.reason, text: E2EE_ERR_TEXT[params.reason] || "服务端未启用端到端加密,已按明文路径继续。" };
|
|
2041
|
+
if (typeof password !== "string" || password.length < 1) return { ok: false, code: "no_password", text: "请输入账号密码" };
|
|
2042
|
+
let mkBytes;
|
|
2043
|
+
try {
|
|
2044
|
+
mkBytes = await wcDeriveMasterKey(password, params.salt, params.kdf);
|
|
2045
|
+
} catch (e) {
|
|
2046
|
+
return { ok: false, code: "derive_failed", text: E2EE_ERR_TEXT.derive_failed };
|
|
2047
|
+
}
|
|
2048
|
+
let r;
|
|
2049
|
+
try {
|
|
2050
|
+
r = await e2eeUnlockHandshake(deviceId, { params, mkBytes });
|
|
2051
|
+
} catch (e) {
|
|
2052
|
+
return { ok: false, code: e && e.code ? e.code : "e2ee_failed", text: (e && e.message) || "解锁失败,请重试" };
|
|
2053
|
+
}
|
|
2054
|
+
const out = e2eeCommitSession(deviceId, params, r);
|
|
2055
|
+
if (out.ok) e2eeMkSave(wcBytesToB64(mkBytes)); // 记住本机解锁(刷新后自动加密)
|
|
2056
|
+
return out;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/**
|
|
2060
|
+
* 桌面授权引导解锁(方案A,2026-09):扫码/一次性链接等「未持有密码」的进入方式,向电脑端
|
|
2061
|
+
* bridge 请求一次性引导(同账号经路由授权);拿到派生 MK 后直接完成同一 hello/probe 握手,
|
|
2062
|
+
* 全程无需输入账号密码。MK 仅在本页内存参与派生,不落盘。信任模型见 docs/e2ee-protocol.md §5.4。
|
|
2063
|
+
*/
|
|
2064
|
+
async function e2eeIntroUnlockDevice(deviceId, mkB64) {
|
|
2065
|
+
if (!e2eeSubtleAvailable()) return { ok: false, code: "no_webcrypto", text: "当前浏览器不支持端到端加密所需的安全环境,请用系统浏览器(HTTPS)重试;已按明文路径继续。" };
|
|
2066
|
+
const params = await e2eeGetParams(true);
|
|
2067
|
+
if (!params.enabled) return { ok: false, code: params.reason, text: E2EE_ERR_TEXT[params.reason] || "服务端未启用端到端加密,已按明文路径继续。" };
|
|
2068
|
+
let mkBytes;
|
|
2069
|
+
try {
|
|
2070
|
+
mkBytes = wcB64ToBytes(mkB64);
|
|
2071
|
+
if (!mkBytes || mkBytes.length !== WC_MK_BYTES) throw new Error("bad len");
|
|
2072
|
+
} catch {
|
|
2073
|
+
return { ok: false, code: "bad_intro", text: "电脑端引导数据无效,请重试或改用账号密码解锁" };
|
|
2074
|
+
}
|
|
2075
|
+
let r;
|
|
2076
|
+
try {
|
|
2077
|
+
r = await e2eeUnlockHandshake(deviceId, { params, mkBytes });
|
|
2078
|
+
} catch (e) {
|
|
2079
|
+
return { ok: false, code: e && e.code ? e.code : "e2ee_failed", text: (e && e.message) || "引导解锁失败,请重试" };
|
|
2080
|
+
}
|
|
2081
|
+
const out = e2eeCommitSession(deviceId, params, r);
|
|
2082
|
+
if (out.ok) e2eeMkSave(mkB64); // 与密码解锁同策略:记住本机,刷新后自动加密
|
|
2083
|
+
return out;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
/** 向电脑端请求一次性 E2EE 引导(方案A)。端点经 relay-router 同账号授权到本设备桥端。 */
|
|
2087
|
+
async function e2eeRequestIntro(deviceId) {
|
|
2088
|
+
try {
|
|
2089
|
+
const r = await fetch(`${window.location.origin}/remote/${encodeURIComponent(deviceId)}/_e2ee/intro`, {
|
|
2090
|
+
method: "POST",
|
|
2091
|
+
headers: { "content-type": "application/json" },
|
|
2092
|
+
body: JSON.stringify({ v: 2, grant: "desktop-intro", ts: Date.now() }),
|
|
2093
|
+
signal: AbortSignal.timeout(8000),
|
|
2094
|
+
cache: "no-store"
|
|
2095
|
+
});
|
|
2096
|
+
const d = await r.json().catch(() => null);
|
|
2097
|
+
if (!d) return { ok: false, code: "bad_intro", text: "电脑端引导响应异常(" + r.status + ")" };
|
|
2098
|
+
if (r.status !== 200 || d.ok !== true || typeof d.mk !== "string") {
|
|
2099
|
+
const code = (d && d.error && d.error.code) || "intro_failed";
|
|
2100
|
+
return { ok: false, code: code, text: (d && d.error && d.error.message) || ("电脑端拒绝引导(" + r.status + ")") };
|
|
2101
|
+
}
|
|
2102
|
+
return { ok: true, mk: d.mk };
|
|
2103
|
+
} catch (e) {
|
|
2104
|
+
return { ok: false, code: "intro_network", text: "电脑端引导请求失败:" + (e && e.message ? e.message : e) };
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** 会话状态查询(供 Phase-4 插件/徽标读取;字段名即对外约定)。 */
|
|
2109
|
+
function e2eeStateFor(deviceId) {
|
|
2110
|
+
const params = e2eeClient.params;
|
|
2111
|
+
const s = deviceId ? e2eeClient.sessions.get(deviceId) : null;
|
|
2112
|
+
return {
|
|
2113
|
+
supported: !!(params && params.enabled === true),
|
|
2114
|
+
enabled: !!(params && params.enabled === true),
|
|
2115
|
+
serverReason: params ? params.reason : "not_checked",
|
|
2116
|
+
unlocked: !!s,
|
|
2117
|
+
deviceId: deviceId || "",
|
|
2118
|
+
sessId: s ? s.sess.sessId : "",
|
|
2119
|
+
profile: s ? s.sess.profile : (params ? params.profile : ""),
|
|
2120
|
+
epoch: s ? s.sess.epoch : (params ? params.epoch : 0)
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/** 更新 header 加密状态徽标(已解锁=绿/仅账号支持=黄/不支持隐藏)。 */
|
|
2125
|
+
function e2eeUpdateBadge() {
|
|
2126
|
+
const el = $("dsh-e2ee-badge");
|
|
2127
|
+
if (!el) return;
|
|
2128
|
+
const sessions = [...e2eeClient.sessions.values()];
|
|
2129
|
+
if (sessions.length > 0) {
|
|
2130
|
+
el.className = "ok";
|
|
2131
|
+
el.title = "本页已建立端到端加密会话(密钥仅存内存)";
|
|
2132
|
+
el.textContent = "🔒 已解锁";
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
const p = e2eeClient.params;
|
|
2136
|
+
if (p && p.enabled === true) {
|
|
2137
|
+
el.className = "warn";
|
|
2138
|
+
el.textContent = "🔒 可加密";
|
|
2139
|
+
el.title = "账号已启用端到端加密:点选支持加密的设备时需先解锁";
|
|
2140
|
+
} else {
|
|
2141
|
+
el.className = "hidden";
|
|
2142
|
+
el.textContent = "";
|
|
2143
|
+
el.title = "";
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// ---------- E2EE 解锁层 UI(仅 E2EE 能力设备出现;密钥/口令绝不落盘) ----------
|
|
2148
|
+
let unlockCtx = null; // { deviceId, name, after }
|
|
2149
|
+
|
|
2150
|
+
function e2eeOpenUnlock(deviceId, name, after) {
|
|
2151
|
+
unlockCtx = { deviceId, name: name || deviceId, after: after || null };
|
|
2152
|
+
$("unlock-device-name").textContent = unlockCtx.name;
|
|
2153
|
+
$("unlock-msg").className = "e2ee-unlock-msg";
|
|
2154
|
+
$("unlock-msg").textContent = "";
|
|
2155
|
+
$("unlock-pass").value = "";
|
|
2156
|
+
$("unlock-modal").classList.remove("hidden");
|
|
2157
|
+
$("unlock-modal").querySelector("#btn-unlock-go").disabled = false;
|
|
2158
|
+
setTimeout(() => { try { $("unlock-pass").focus(); } catch {} }, 60);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function e2eeCloseUnlock() {
|
|
2162
|
+
$("unlock-modal").classList.add("hidden");
|
|
2163
|
+
unlockCtx = null;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
async function e2eeDoUnlock() {
|
|
2167
|
+
const go = $("btn-unlock-go");
|
|
2168
|
+
const msg = $("unlock-msg");
|
|
2169
|
+
if (!unlockCtx) return;
|
|
2170
|
+
const deviceId = unlockCtx.deviceId;
|
|
2171
|
+
const pw = $("unlock-pass").value;
|
|
2172
|
+
if (!pw) { msg.className = "e2ee-unlock-msg err"; msg.textContent = "请输入账号密码"; return; }
|
|
2173
|
+
go.disabled = true;
|
|
2174
|
+
msg.className = "e2ee-unlock-msg";
|
|
2175
|
+
msg.innerHTML = '<span class="loading"></span> 正在派生密钥并与电脑端握手…(约 1–2 秒)';
|
|
2176
|
+
const r = await e2eeUnlockDevice(deviceId, pw);
|
|
2177
|
+
$("unlock-pass").value = ""; // 口令用完即清,不留在 DOM
|
|
2178
|
+
if (r.ok) {
|
|
2179
|
+
msg.className = "e2ee-unlock-msg ok";
|
|
2180
|
+
msg.textContent = "✅ 已与电脑端确认密码一致,加密会话就绪(密钥仅存本页内存)。";
|
|
2181
|
+
if (pw) e2eePendingPassword = pw; // 纠正成功后同步内存密码,后续设备不再要求重复输入
|
|
2182
|
+
const after = unlockCtx.after;
|
|
2183
|
+
const name = unlockCtx.name;
|
|
2184
|
+
go.disabled = false;
|
|
2185
|
+
go.textContent = "进入设备";
|
|
2186
|
+
unlockCtx.after = null;
|
|
2187
|
+
setTimeout(() => { e2eeCloseUnlock(); if (after) after(); }, 900);
|
|
2188
|
+
toast(`🔒 ${name} 端到端加密会话已建立`, 2600);
|
|
2189
|
+
} else {
|
|
2190
|
+
msg.className = "e2ee-unlock-msg err";
|
|
2191
|
+
msg.textContent = "解锁失败:" + (r.text || "未知错误");
|
|
2192
|
+
go.disabled = false;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/** 「暂不加密(明文)」:协议 §7.3 明确二次确认 → 明文进入 + 徽标明示。 */
|
|
2197
|
+
function e2eeSkipUnlock() {
|
|
2198
|
+
if (!unlockCtx) return;
|
|
2199
|
+
const id = unlockCtx.deviceId;
|
|
2200
|
+
const name = unlockCtx.name;
|
|
2201
|
+
e2eeCloseUnlock();
|
|
2202
|
+
toast(`⚠ ${name}:本次连接未加密(明文)。点选设备将直接进入镜像页。`, 4200);
|
|
2203
|
+
enterDevice(id);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
/** e2eeGetParams 的限时版:超时(默认 1200ms)按「未启用」处理 —— 门控绝不让明文进入被拖慢。 */
|
|
2207
|
+
async function e2eeParamsWithin(ms = 1200, force = false) {
|
|
2208
|
+
return Promise.race([
|
|
2209
|
+
e2eeGetParams(force),
|
|
2210
|
+
new Promise((r) => setTimeout(() => r({ enabled: false, reason: "params_slow" }), ms))
|
|
2211
|
+
]);
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/**
|
|
2215
|
+
* 设备点选入口(E2EE 门控):账号启用 e2ee ∧ 设备声明 e2ee-v2 → 弹解锁层;
|
|
2216
|
+
* 否则完全走原明文进入流程(行为零变化,回归保护)。 */
|
|
2217
|
+
async function e2eeMaybeGateThenEnter(deviceId, name, caps) {
|
|
2218
|
+
const params = await e2eeParamsWithin(1200, false);
|
|
2219
|
+
e2eeUpdateBadge();
|
|
2220
|
+
if (!e2eeCapable(params, caps)) {
|
|
2221
|
+
enterDevice(deviceId); // 未启用/无能力:与旧版完全一致
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
if (!e2eeSubtleAvailable()) {
|
|
2225
|
+
// 浏览器无 WebCrypto(非安全上下文等):不弹层,明文进入 + 原因提示(协议 §7.1 不回退静默)
|
|
2226
|
+
toast("⚠ 当前环境不支持端到端加密(需 HTTPS 安全上下文),本次连接为明文", 5000);
|
|
2227
|
+
enterDevice(deviceId);
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
e2eeClient.deviceName.set(deviceId, name || deviceId);
|
|
2231
|
+
// 已解锁过(同页内存会话)→ 直接进入;但桥端会话 24h TTL(e2ee-client prune),
|
|
2232
|
+
// 超过 24h 的内存会话视为失效,避免直进撞桥端已过期会话。
|
|
2233
|
+
const prev = e2eeClient.sessions.get(deviceId);
|
|
2234
|
+
if (prev && prev.sess) {
|
|
2235
|
+
const fresh = !prev.unlockedAt || (Date.now() - prev.unlockedAt < 24 * 3600 * 1000);
|
|
2236
|
+
if (fresh) { enterDevice(deviceId); return; }
|
|
2237
|
+
e2eeClient.sessions.delete(deviceId);
|
|
2238
|
+
}
|
|
2239
|
+
// 免二次输密码(2026-09):依次尝试自动解锁候选,失败按语义降级:
|
|
2240
|
+
// ① 内存密码(本次登录/注册刚输过)→ e2eeUnlockDevice(内部同时落 localStorage);
|
|
2241
|
+
// ② localStorage 记住的派生 MK(刷新/重开仍加密,2026-09 用户决策);
|
|
2242
|
+
// ③ 桌面授权引导(扫码/一次性链接等从未持有密码,方案A)。
|
|
2243
|
+
// 密码不一致/会话失效类 → 弹解锁层人工纠正(绝不因 bad_key 静默明文);
|
|
2244
|
+
// 服务端未启用/无能力等 → toast 提示后明文进入(与既有分支语义一致)。
|
|
2245
|
+
const autoResult = async (label, fn, okText) => {
|
|
2246
|
+
try {
|
|
2247
|
+
const r = await fn();
|
|
2248
|
+
if (r.ok) {
|
|
2249
|
+
toast(okText || `🔒 ${name || deviceId} 端到端加密会话已建立`, 2200);
|
|
2250
|
+
enterDevice(deviceId);
|
|
2251
|
+
return "entered";
|
|
2252
|
+
}
|
|
2253
|
+
const needManual = ["bad_key", "auth_failed", "params", "unknown_session", "not_verified", "replay", "ws_closed", "ws_timeout", "derive_failed", "bad_intro", "intro_failed", "intro_network"];
|
|
2254
|
+
if (!needManual.includes(r.code)) {
|
|
2255
|
+
toast((r.text || "已按明文路径继续"), 3500);
|
|
2256
|
+
enterDevice(deviceId);
|
|
2257
|
+
return "plain";
|
|
2258
|
+
}
|
|
2259
|
+
if (label !== "intro") toast(`⚠ ${label}未成功(${r.code})`, 3600);
|
|
2260
|
+
} catch (e) { /* 落到下一候选/模态 */ }
|
|
2261
|
+
return "manual";
|
|
2262
|
+
};
|
|
2263
|
+
if (e2eePendingPassword) {
|
|
2264
|
+
const st = await autoResult("自动解锁", () => e2eeUnlockDevice(deviceId, e2eePendingPassword), `🔒 ${name || deviceId} 端到端加密会话已自动建立`);
|
|
2265
|
+
if (st !== "manual") return;
|
|
2266
|
+
}
|
|
2267
|
+
if (!e2eePendingPassword && e2eeStoredMk) {
|
|
2268
|
+
// 记住本机:用 localStorage 里的派生 MK 直接自动解锁(刷新后仍加密,无需再输密码)
|
|
2269
|
+
const st = await autoResult("记住本机解锁", () => e2eeIntroUnlockDevice(deviceId, e2eeStoredMk), `🔒 ${name || deviceId} 已自动加密进入(记住本机)`);
|
|
2270
|
+
if (st !== "manual") return;
|
|
2271
|
+
}
|
|
2272
|
+
if (!e2eePendingPassword && !e2eeStoredMk) {
|
|
2273
|
+
// 桌面授权引导(扫码/一次性链接等无密码路径,方案A):电脑端 bridge 经同账号授权通道下发 MK
|
|
2274
|
+
const intro = await e2eeRequestIntro(deviceId).catch(() => ({ ok: false, code: "intro_network", text: "引导请求失败" }));
|
|
2275
|
+
if (intro.ok) {
|
|
2276
|
+
const st = await autoResult("intro", () => e2eeIntroUnlockDevice(deviceId, intro.mk), `🔒 ${name || deviceId} 已由电脑端授权自动建立加密会话`);
|
|
2277
|
+
if (st !== "manual") return;
|
|
2278
|
+
toast("⚠ 自动引导未成功,可改用账号密码解锁", 3600);
|
|
2279
|
+
} else if (["no_webcrypto", "disabled", "e2ee_disabled", "no_cap"].includes(intro.code)) {
|
|
2280
|
+
toast((intro.text || "已按明文路径继续"), 3500);
|
|
2281
|
+
enterDevice(deviceId);
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
// 引导不可达/被拒(旧电脑端等):落到下方模态,让用户输密码
|
|
2285
|
+
}
|
|
2286
|
+
e2eeOpenUnlock(deviceId, name, () => enterDevice(deviceId));
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// ---------- E2EE 信封 HTTP/WS 传输原语(浏览器侧;Phase-4 镜像页/插件接线用) ----------
|
|
2290
|
+
// 语义对照桥端/路由:
|
|
2291
|
+
// - HTTP 信封明文 p =「上游路径」= router resolveRoute 的转发路径(剥 /remote/<deviceId|channel>
|
|
2292
|
+
// 前缀、channel 形态剥 device 参数;§6.2)——桥端按该路径 fetch 127.0.0.1:3080;
|
|
2293
|
+
// - 数据 WS 的 wsLabel = 桥端 parseWsE2eeParams 看到的上游 path+query(剥 e2ee、保留 w)。
|
|
2294
|
+
|
|
2295
|
+
/** 上游路径(镜像 router resolveRoute:剥 /remote/ 前缀;channel 形态保留首段标记;剥 device 参数)。 */
|
|
2296
|
+
function wcUpstreamPathOf(fullPath) {
|
|
2297
|
+
let p = String(fullPath || "");
|
|
2298
|
+
let query = "";
|
|
2299
|
+
const qIdx = p.indexOf("?");
|
|
2300
|
+
if (qIdx !== -1) { query = p.slice(qIdx + 1); p = p.slice(0, qIdx); }
|
|
2301
|
+
const params = new URLSearchParams(query);
|
|
2302
|
+
params.delete("device"); // router 对 channel 形态剥 device 参数(§6.2)
|
|
2303
|
+
let path = p;
|
|
2304
|
+
if (p.startsWith("/remote/")) {
|
|
2305
|
+
const rest = p.slice("/remote/".length);
|
|
2306
|
+
const slash = rest.indexOf("/");
|
|
2307
|
+
const head = slash === -1 ? rest : rest.slice(0, slash);
|
|
2308
|
+
const tail = slash === -1 ? "" : rest.slice(slash);
|
|
2309
|
+
const marker = ["api", "sidebar", "git", "pet", "_e2ee"].includes(head);
|
|
2310
|
+
path = marker ? "/" + head + tail : (tail || "/");
|
|
2311
|
+
}
|
|
2312
|
+
const qs = params.size ? "?" + params.toString() : "";
|
|
2313
|
+
return path + qs;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/** 数据 WS 流标签(e2ee 标记去掉后保留 w 的 path+query;与桥端 parseWsE2eeParams 同构)。 */
|
|
2317
|
+
function wcWsLabelOf(fullPath) {
|
|
2318
|
+
const up = wcUpstreamPathOf(fullPath); // 保持 e2ee/w 参数,剥 device
|
|
2319
|
+
const qIdx = up.indexOf("?");
|
|
2320
|
+
const path = qIdx === -1 ? up : up.slice(0, qIdx);
|
|
2321
|
+
const params = new URLSearchParams(qIdx === -1 ? "" : up.slice(qIdx + 1));
|
|
2322
|
+
const sessId = params.get("e2ee") || "";
|
|
2323
|
+
const w = params.get("w") || "";
|
|
2324
|
+
if (!sessId || !WC_SESS_ID_RE.test(sessId)) return null;
|
|
2325
|
+
params.delete("e2ee");
|
|
2326
|
+
return { sessId, w, wsLabel: path + (params.size ? "?" + params.toString() : ""), upstreamPath: path };
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/**
|
|
2330
|
+
* 用已解锁会话封装一个 HTTP 请求并解密响应(§4.3)。
|
|
2331
|
+
* 请求:外头 content-type: application/vnd.dsh.e2ee-v2 + x-dsh-e2ee;
|
|
2332
|
+
* 信封明文 {m,p,h,b}(p = 上游路径);响应信封(k=http-resp)解密 → {st,h,enc,b}(enc=gzip 解压)。
|
|
2333
|
+
* 无信封标记的响应(502/x-dsh-e2ee-error、router 403/502/504)→ 原样透传(调用方展示)。
|
|
2334
|
+
* @param {string} deviceId 已解锁设备
|
|
2335
|
+
* @param {string} routerUrl 手机侧 URL(如 `${origin}/remote/<deviceId>/api/...` 或 channel 形态)
|
|
2336
|
+
* @param {object} o { method, path?(显式上游路径,缺省按 routerUrl 推导), headers, body }
|
|
2337
|
+
*/
|
|
2338
|
+
async function e2eeFetchEnveloped(deviceId, routerUrl, { method = "GET", path, headers = {}, body } = {}) {
|
|
2339
|
+
const ent = e2eeClient.sessions.get(deviceId);
|
|
2340
|
+
if (!ent || !ent.sess) throw new Error("e2ee: 会话未解锁(deviceId=" + deviceId + ")");
|
|
2341
|
+
const sess = ent.sess;
|
|
2342
|
+
const url = new URL(routerUrl, window.location.href);
|
|
2343
|
+
const upstreamPath = path || wcUpstreamPathOf(url.pathname + url.search);
|
|
2344
|
+
const n = wcRandomBytes(WC_NONCE_BYTES);
|
|
2345
|
+
const nonceB64 = wcBytesToB64(n);
|
|
2346
|
+
const reqPlain = wcEncodeHttpRequestPlain({
|
|
2347
|
+
method,
|
|
2348
|
+
path: upstreamPath,
|
|
2349
|
+
headers,
|
|
2350
|
+
bodyB64: body ? wcBytesToB64(typeof body === "string" ? wcUtf8(body) : body) : ""
|
|
2351
|
+
});
|
|
2352
|
+
const envReq = await sess.seal({ kind: "http", dir: WC_DIR_P2B, counter: 0, data: reqPlain, nonceBytes: n });
|
|
2353
|
+
const res = await fetch(url.href, {
|
|
2354
|
+
method: "POST",
|
|
2355
|
+
headers: { ...wcEnvelopeHeaders(sess.sessId, "http"), cookie: document.cookie },
|
|
2356
|
+
body: JSON.stringify(envReq)
|
|
2357
|
+
});
|
|
2358
|
+
const ct = String(res.headers.get("content-type") || "");
|
|
2359
|
+
if (ct.startsWith(WC_ENVELOPE_CONTENT_TYPE) && res.headers.get(WC_ENVELOPE_HEADER)) {
|
|
2360
|
+
const envResp = await res.json();
|
|
2361
|
+
const opened = await sess.open({ kind: "http-resp", dir: WC_DIR_B2P, env: envResp, counter: envResp.c ?? 0, reqNonceB64: nonceB64 });
|
|
2362
|
+
const rp = wcDecodeHttpResponsePlain(opened.data);
|
|
2363
|
+
let bodyBytes = rp.bodyB64 ? wcB64ToBytes(rp.bodyB64) : new Uint8Array(0);
|
|
2364
|
+
if (rp.enc === "gzip" && bodyBytes.length) {
|
|
2365
|
+
const un = await wcGunzip(bodyBytes);
|
|
2366
|
+
if (un) bodyBytes = un;
|
|
2367
|
+
}
|
|
2368
|
+
return { status: rp.status, headers: rp.headers, body: bodyBytes, plain: false };
|
|
2369
|
+
}
|
|
2370
|
+
// 无信封标记:router/bridge 错误页等 → 原样透传(调用方展示 502/403/504 语义)
|
|
2371
|
+
const text = await res.text();
|
|
2372
|
+
return { status: res.status, headers: Object.fromEntries([...res.headers.entries()]), body: text, plain: true, raw: text };
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* 建一条 E2EE 数据 WS(§4.6):在手机侧 ws URL 上追加 &e2ee=<sessId>&w=<8B hex(=16 hex 字符)>;
|
|
2377
|
+
* 每条消息为 k:"w" 信封;p2b 方向计数自 0 单调;b2p 收到后按信封 c 校验单调。
|
|
2378
|
+
* 事件:{ onOpen({ws, wsLabel}), onEnvelope({data:Uint8Array, t}), onPlain(raw), onError(e), onClose(ev) }。
|
|
2379
|
+
* @param {string} deviceId
|
|
2380
|
+
* @param {string} wsUrl 手机侧 ws 地址(允许相对;device 或 channel 形态)
|
|
2381
|
+
*/
|
|
2382
|
+
function e2eeOpenDataWs(deviceId, wsUrl, { onOpen, onEnvelope, onPlain, onClose, onError } = {}) {
|
|
2383
|
+
const ent = e2eeClient.sessions.get(deviceId);
|
|
2384
|
+
if (!ent || !ent.sess) throw new Error("e2ee: 会话未解锁(deviceId=" + deviceId + ")");
|
|
2385
|
+
const sess = ent.sess;
|
|
2386
|
+
const u = new URL(wsUrl, window.location.href);
|
|
2387
|
+
u.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
2388
|
+
const params = new URLSearchParams(u.search);
|
|
2389
|
+
if (!params.get("w")) params.set("w", wcRandomHex(8)); // 8B 随机 = 16 hex
|
|
2390
|
+
params.set("e2ee", sess.sessId);
|
|
2391
|
+
const sentPath = u.pathname + (params.size ? "?" + params.toString() : "");
|
|
2392
|
+
const label = wcWsLabelOf(sentPath);
|
|
2393
|
+
if (!label) throw new Error("e2ee: 数据 WS 缺少 e2ee/w 标记(内部错误)");
|
|
2394
|
+
const wsLabel = label.wsLabel; // 与桥端一致的流标签(含 w)
|
|
2395
|
+
const ws = new WebSocket(u.origin + sentPath);
|
|
2396
|
+
let p2b = 0;
|
|
2397
|
+
let b2pLast = -1;
|
|
2398
|
+
ws.onopen = () => { if (onOpen) onOpen({ ws, wsLabel }); };
|
|
2399
|
+
ws.onmessage = (ev) => {
|
|
2400
|
+
const raw = String(ev.data);
|
|
2401
|
+
void (async () => {
|
|
2402
|
+
let env = null;
|
|
2403
|
+
try { env = JSON.parse(raw); } catch { /* 非 JSON 明文帧 */ }
|
|
2404
|
+
if (env && typeof env === "object" && env.k === "w") {
|
|
2405
|
+
try {
|
|
2406
|
+
if (!Number.isInteger(env.c) || env.c <= b2pLast) throw wcErr("replay", "e2ee: b2p 计数重放/乱序");
|
|
2407
|
+
const opened = await sess.open({ kind: "w", dir: WC_DIR_B2P, env, counter: env.c ?? 0, wsLabel });
|
|
2408
|
+
b2pLast = env.c;
|
|
2409
|
+
if (onEnvelope) onEnvelope({ data: opened.data, t: opened.t, env });
|
|
2410
|
+
} catch (e) {
|
|
2411
|
+
if (onError) onError(e); else try { ws.close(1008, "⚠ e2ee 消息无法解密"); } catch {}
|
|
2412
|
+
}
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
if (onPlain) onPlain(raw);
|
|
2416
|
+
})();
|
|
2417
|
+
};
|
|
2418
|
+
ws.onerror = (e) => { if (onError) onError(e); };
|
|
2419
|
+
ws.onclose = (ev) => { if (onClose) onClose(ev); };
|
|
2420
|
+
return {
|
|
2421
|
+
ws,
|
|
2422
|
+
send(data, { binary = false } = {}) {
|
|
2423
|
+
return (async () => {
|
|
2424
|
+
const env = await sess.seal({ kind: "w", dir: WC_DIR_P2B, counter: p2b++, data, t: binary ? 1 : 0, wsLabel });
|
|
2425
|
+
ws.send(JSON.stringify(env));
|
|
2426
|
+
})();
|
|
2427
|
+
},
|
|
2428
|
+
close(code, reason) { try { ws.close(code, reason); } catch {} }
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
|
|
2433
|
+
$("btn-login").onclick = () => doLogin();
|
|
2434
|
+
$("btn-register").onclick = () => doRegister();
|
|
2435
|
+
$("btn-logout").onclick = () => logout();
|
|
2436
|
+
$("btn-logout-inline").onclick = () => logout();
|
|
2437
|
+
$("tab-login").onclick = () => switchAuthTab("login");
|
|
2438
|
+
$("tab-register").onclick = () => switchAuthTab("register");
|
|
2439
|
+
$("login-captcha-img").onclick = () => loadCaptchaInto("login-captcha-img");
|
|
2440
|
+
$("login-sms-captcha-img").onclick = () => loadCaptchaInto("login-sms-captcha-img");
|
|
2441
|
+
$("reg-sms-captcha-img").onclick = () => loadCaptchaInto("reg-sms-captcha-img");
|
|
2442
|
+
$("btn-send-login-sms").onclick = () => sendSms("login");
|
|
2443
|
+
$("btn-send-reg-sms").onclick = () => sendSms("register");
|
|
2444
|
+
$("btn-toggle-login-sms").onclick = () => toggleLoginMode();
|
|
2445
|
+
$("btn-toggle-login-local").onclick = () => toggleLocalLogin();
|
|
2446
|
+
$("btn-local-login").onclick = () => doLocalLogin();
|
|
2447
|
+
// 忘记密码(短信重置):登录卡 → 重置小表单(往返 + 发码 + 提交)
|
|
2448
|
+
$("btn-forgot-pass").onclick = () => openResetForm();
|
|
2449
|
+
$("btn-back-to-login").onclick = () => closeResetForm();
|
|
2450
|
+
$("btn-send-reset-sms").onclick = () => sendSms("reset");
|
|
2451
|
+
$("reset-captcha-img").onclick = () => loadCaptchaInto("reset-captcha-img");
|
|
2452
|
+
$("btn-password-reset").onclick = () => doPasswordReset();
|
|
2453
|
+
$("reset-pass").addEventListener("keydown", (e) => { if (e.key === "Enter") $("btn-password-reset").onclick(); });
|
|
2454
|
+
$("btn-refresh").onclick = () => { resetDevicePoll(); void refreshDevices(); };
|
|
2455
|
+
$("btn-manage-devices").onclick = toggleManageDevices;
|
|
2456
|
+
// Router 引导提示条(会话过期/设备离线):可手动关闭
|
|
2457
|
+
if ($("entry-hint-close")) $("entry-hint-close").onclick = () => $("entry-hint").classList.add("hidden");
|
|
2458
|
+
$("login-pass").addEventListener("keydown", (e) => { if (e.key === "Enter") $("btn-login").onclick(); });
|
|
2459
|
+
$("reg-pass2").addEventListener("keydown", (e) => { if (e.key === "Enter") $("btn-register").onclick(); });
|
|
2460
|
+
// E2EE 解锁层(Phase-3):解锁 / 明文跳过 / Enter 提交;遮罩点击不误关(输入中)
|
|
2461
|
+
$("btn-unlock-go").onclick = () => void e2eeDoUnlock();
|
|
2462
|
+
$("btn-unlock-skip").onclick = () => e2eeSkipUnlock();
|
|
2463
|
+
$("unlock-pass").addEventListener("keydown", (e) => { if (e.key === "Enter" && !$("btn-unlock-go").disabled) void e2eeDoUnlock(); });
|
|
2464
|
+
|
|
2465
|
+
// ---------- 启动 ----------
|
|
2466
|
+
function tokenExpired(token) {
|
|
2467
|
+
try {
|
|
2468
|
+
const parts = token.split(".");
|
|
2469
|
+
if (parts.length !== 3) return true;
|
|
2470
|
+
const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
2471
|
+
return typeof payload.exp === "number" && payload.exp < Math.floor(Date.now() / 1000);
|
|
2472
|
+
} catch { return true; }
|
|
2473
|
+
}
|
|
2474
|
+
(async function init() {
|
|
2475
|
+
await loadPublicConfig();
|
|
2476
|
+
// /app/promo:本版本不含升级/付费页;保留路径兼容 —— 带查询参数原样回应用根路径,
|
|
2477
|
+
// 因此一次性登录(/app/promo?auth=KEY)与邀请链接(?invite=CODE)照旧可用。
|
|
2478
|
+
if (window.location.pathname.endsWith("/promo")) {
|
|
2479
|
+
window.location.replace("/app/" + (window.location.search || ""));
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
// 显式退出意图(/app/?logout=1,Router 错误页「返回登录」带此参数):
|
|
2483
|
+
// 先同步清理本地令牌、用户数据与 dsh_token/dsh_device Cookie,再渲染登录页,
|
|
2484
|
+
// 避免旧令牌残留导致自动重进;随后清理地址栏参数。
|
|
2485
|
+
if (new URLSearchParams(window.location.search).get("logout") === "1") {
|
|
2486
|
+
logout();
|
|
2487
|
+
history.replaceState(null, "", window.location.pathname);
|
|
2488
|
+
setView("login");
|
|
2489
|
+
switchAuthTab("login");
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
// 一次性扫码登录(/app/?auth=KEY,电脑端生成,30 分钟/一次性):
|
|
2493
|
+
// 有 auth 一律走 exchange(无论当前是否已登录),成败去向由 doAuthExchangeLogin 决定
|
|
2494
|
+
// Router 的引导回跳(/app/?reason=…):镜像页出问题时不再甩报错页,而是把用户送回这里。
|
|
2495
|
+
const entryReason = (new URLSearchParams(window.location.search).get("reason") || "").trim();
|
|
2496
|
+
const authKey = (new URLSearchParams(window.location.search).get("auth") || "").trim();
|
|
2497
|
+
if (authKey) {
|
|
2498
|
+
await doAuthExchangeLogin(authKey);
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
// 邀请链接(/app/?invite=CODE):预填邀请码到注册流程,并给出可见提示
|
|
2502
|
+
const inviteParam = new URLSearchParams(window.location.search).get("invite");
|
|
2503
|
+
if (inviteParam) {
|
|
2504
|
+
window.__inviteCode = inviteParam.trim().toUpperCase();
|
|
2505
|
+
history.replaceState(null, "", window.location.pathname);
|
|
2506
|
+
const hint = document.createElement("div");
|
|
2507
|
+
hint.className = "auth-msg ok";
|
|
2508
|
+
hint.style.marginBottom = "10px";
|
|
2509
|
+
hint.style.fontSize = "15px";
|
|
2510
|
+
hint.style.fontWeight = "600";
|
|
2511
|
+
hint.textContent = `🎁 已通过好友邀请注册,邀请码 ${window.__inviteCode} 将自动生效`;
|
|
2512
|
+
const form = $("auth-register-form");
|
|
2513
|
+
if (form) form.insertBefore(hint, form.firstChild);
|
|
2514
|
+
}
|
|
2515
|
+
if (state.token && !tokenExpired(state.token)) {
|
|
2516
|
+
enterMirror(state.token); // 会话恢复 → Router 实时设备列表
|
|
2517
|
+
handleEntryReason(entryReason); // Router 引导提示(会话过期/设备离线/设备不可用)
|
|
2518
|
+
} else {
|
|
2519
|
+
if (state.token) logout();
|
|
2520
|
+
setView("login"); switchAuthTab("login");
|
|
2521
|
+
if (entryReason) handleEntryReason(entryReason === "expired" ? "expired" : entryReason);
|
|
2522
|
+
}
|
|
2523
|
+
})();
|
|
2524
|
+
</script>
|
|
2525
|
+
</body>
|
|
2526
|
+
</html>
|