dsh-remote-panel 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/deploy/templates/Caddyfile.tmpl +7 -1
- package/lib/assets/icon-180.png +0 -0
- package/lib/assets/icon-192.png +0 -0
- package/lib/assets/icon-512.png +0 -0
- package/lib/assets/icon-maskable-512.png +0 -0
- package/lib/client.js +323 -33
- package/lib/contract.js +73 -0
- package/lib/index.js +54 -9
- package/lib/pair.js +5 -5
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -64,6 +64,22 @@ Caddy 的 `pki/authorities/local/root.crt`)。
|
|
|
64
64
|
- Android:设置 → 安全 → 加密与凭据 → 安装证书 → CA 证书。
|
|
65
65
|
- iOS:下载后到 设置 → 通用 → 关于本机 → 证书信任设置 里打开信任。
|
|
66
66
|
|
|
67
|
+
## 安全模型:令牌只在门禁之后
|
|
68
|
+
|
|
69
|
+
手机端有两道门:Caddy 的口令门禁(`dsh_gate` Cookie)+ DSH 自己的会话令牌。两者的取用顺序是:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
扫码/点链接 → /pair?k=口令(种门禁 Cookie) → /remote-panel/login(要 Cookie,Caddy 直接发)
|
|
73
|
+
→ /?token=…(换取 DSH 会话)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- 二维码里**只有口令**,没有令牌;
|
|
77
|
+
- 带令牌的跳转页 `login.html` 放在门禁**之后**,没有 Cookie 拿不到(实测 401);
|
|
78
|
+
- 免鉴权的 `/remote-panel/app/*` 只放静态资源(manifest / 图标 / 根证书 / APK),不含任何凭据;
|
|
79
|
+
- 旧版 APK 的入口 `go.html` 已改成「请更新」提示页,不会再泄露令牌。
|
|
80
|
+
|
|
81
|
+
APK 里内置了门禁口令(启动即过 `/pair`),所以**换口令后需要重新构建壳**。
|
|
82
|
+
|
|
67
83
|
## 手机 App(WebView 壳)
|
|
68
84
|
|
|
69
85
|
PWA「安装应用」在国行 Android 上装不成:WebAPK 打包依赖 Google Play 服务,且打包服务器要能从公网
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
handle /pair {
|
|
26
26
|
header Set-Cookie "dsh_gate=@@ACCESS_KEY@@; Max-Age=31536000; Path=/; HttpOnly; SameSite=Lax"
|
|
27
27
|
header Content-Type "text/html; charset=utf-8"
|
|
28
|
-
respond `<!doctype html><meta charset="utf-8"><title>正在连接…</title><script>const t=new URLSearchParams(location.search).get("token");location.replace(t?"/?token="+encodeURIComponent(t):"/")</script>` 200
|
|
28
|
+
respond `<!doctype html><meta charset="utf-8"><title>正在连接…</title><script>const t=new URLSearchParams(location.search).get("token");location.replace(t?"/?token="+encodeURIComponent(t):"/remote-panel/login")</script>` 200
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
# 诊断用:不经过任何认证
|
|
@@ -62,6 +62,12 @@
|
|
|
62
62
|
# Caddy 默认没有 cookie 匹配器,用 Header 通配匹配替代
|
|
63
63
|
@paired header Cookie "*dsh_gate=@@ACCESS_KEY@@*"
|
|
64
64
|
handle @paired {
|
|
65
|
+
# 带令牌的跳转页:必须过了门禁才能拿
|
|
66
|
+
handle /remote-panel/login* {
|
|
67
|
+
root * @@APP_DIR@@
|
|
68
|
+
rewrite * /login.html
|
|
69
|
+
file_server
|
|
70
|
+
}
|
|
65
71
|
# 关键:不重写 Host/Origin。DSH 通过 --trusted-host 接受这个 authority,
|
|
66
72
|
# Cookie 因此天然同源,不需要任何注入或伪造。
|
|
67
73
|
reverse_proxy 127.0.0.1:@@UPSTREAM_PORT@@
|
package/lib/assets/icon-180.png
CHANGED
|
Binary file
|
package/lib/assets/icon-192.png
CHANGED
|
Binary file
|
package/lib/assets/icon-512.png
CHANGED
|
Binary file
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -545,11 +545,14 @@ window.__ModuleLoader__.load({
|
|
|
545
545
|
const [probe, setProbe] = useState(null)
|
|
546
546
|
const [rejects, setRejects] = useState([])
|
|
547
547
|
const [busy, setBusy] = useState(false)
|
|
548
|
+
const [shell, setShell] = useState(null)
|
|
548
549
|
|
|
549
550
|
const refresh = useCallback(async () => {
|
|
550
551
|
setBusy(true)
|
|
551
552
|
try {
|
|
552
|
-
|
|
553
|
+
const [probed, status] = await Promise.all([callApi('/probe'), callApi('/status')])
|
|
554
|
+
setProbe(probed.probe)
|
|
555
|
+
setShell(status.shell ?? null)
|
|
553
556
|
setRejects((await callApi('/rejects')).entries)
|
|
554
557
|
} catch {
|
|
555
558
|
/* 状态探测失败时保留上一帧 */
|
|
@@ -572,6 +575,8 @@ window.__ModuleLoader__.load({
|
|
|
572
575
|
// 内部 CA 的叶子证书只有 12 小时,续签失败时探活照样握手成功(rejectUnauthorized:false),
|
|
573
576
|
// 所以单独看它:剩余不足 2 小时或 SAN 不匹配都算红灯
|
|
574
577
|
[certHours === null ? '证书' : `证书 ${certHours}h`, cert === undefined ? undefined : { ok: cert.ok === true }],
|
|
578
|
+
// 手机外壳依赖 DSH 的 hash 类名:DSH 升级后这里会红,提示需要更新插件
|
|
579
|
+
['外壳适配', shell === null ? undefined : { ok: shell.ok === true }],
|
|
575
580
|
]
|
|
576
581
|
|
|
577
582
|
return jsxs(Card, {
|
|
@@ -591,6 +596,12 @@ window.__ModuleLoader__.load({
|
|
|
591
596
|
}),
|
|
592
597
|
],
|
|
593
598
|
}),
|
|
599
|
+
shell !== null && shell.ok !== true
|
|
600
|
+
? jsx('p', {
|
|
601
|
+
style: { ...styles.hint, color: '#f2b8b5' },
|
|
602
|
+
children: `外壳挂点缺失:${(shell.missing ?? []).join(', ') || '未知'} —— DSH 升级后需要更新插件,否则手机样式会错位`,
|
|
603
|
+
})
|
|
604
|
+
: null,
|
|
594
605
|
cert !== undefined && cert.ok !== true
|
|
595
606
|
? jsx('p', {
|
|
596
607
|
style: { ...styles.hint, color: '#f2b8b5' },
|
|
@@ -1068,19 +1079,25 @@ window.__ModuleLoader__.load({
|
|
|
1068
1079
|
inset: 0 auto 0 0;
|
|
1069
1080
|
height: auto !important;
|
|
1070
1081
|
z-index: 900;
|
|
1071
|
-
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
1082
|
+
padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px));
|
|
1072
1083
|
border-radius: 0 16px 16px 0 !important;
|
|
1073
1084
|
box-shadow: 0 0 28px rgba(0, 0, 0, .32) !important;
|
|
1074
1085
|
}
|
|
1075
1086
|
/* 独立窗口里别再有下拉刷新 / 橡皮筋 */
|
|
1076
1087
|
body { overscroll-behavior: none; }
|
|
1077
1088
|
|
|
1089
|
+
/* 顶部安全区:默认取 env();WebView 里由壳把真实切角高度写进 --dsh-safe-top */
|
|
1090
|
+
:root {
|
|
1091
|
+
--dsh-safe-top: env(safe-area-inset-top, 0px);
|
|
1092
|
+
--dsh-safe-bottom: env(safe-area-inset-bottom, 0px);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1078
1095
|
/* 顶栏:52px;刘海屏/独立窗口把状态栏让出来 */
|
|
1079
1096
|
.wSkVaW_header {
|
|
1080
|
-
min-height: calc(52px +
|
|
1081
|
-
padding: calc(
|
|
1097
|
+
min-height: calc(52px + var(--dsh-safe-top, 0px)) !important;
|
|
1098
|
+
padding: calc(12px + var(--dsh-safe-top, 0px)) 60px 0 10px !important;
|
|
1082
1099
|
}
|
|
1083
|
-
.wSkVaW_headerSessionless { padding-top: calc(
|
|
1100
|
+
.wSkVaW_headerSessionless { padding-top: calc(12px + var(--dsh-safe-top, 0px)) !important; }
|
|
1084
1101
|
.wSkVaW_headerCorner { display: none !important; }
|
|
1085
1102
|
/* 手机的「用文件管理器打开」是桌面动作,收掉 */
|
|
1086
1103
|
.OMoRSG_split { display: none !important; }
|
|
@@ -1153,11 +1170,16 @@ window.__ModuleLoader__.load({
|
|
|
1153
1170
|
.pXSMma_previewBadge { display: none !important; }
|
|
1154
1171
|
/* 原生把「问候语 + 输入条」一起居中;手机按原型拆开:
|
|
1155
1172
|
问候语留在屏幕中间(原生居中),输入条钉到底部 */
|
|
1173
|
+
/* 会话里的输入条座位:底部要让开手势条(WebView 里 env() 常为 0,由壳注入) */
|
|
1174
|
+
.wSkVaW_composerSeat {
|
|
1175
|
+
padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px)) !important;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1156
1178
|
.wSkVaW_composerHero .uV2eYG_root {
|
|
1157
1179
|
position: fixed;
|
|
1158
1180
|
left: 0;
|
|
1159
1181
|
right: 0;
|
|
1160
|
-
bottom: max(18px, env(safe-area-inset-bottom, 0px));
|
|
1182
|
+
bottom: max(18px, max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px)));
|
|
1161
1183
|
z-index: 55;
|
|
1162
1184
|
}
|
|
1163
1185
|
|
|
@@ -1167,8 +1189,9 @@ window.__ModuleLoader__.load({
|
|
|
1167
1189
|
left: 0;
|
|
1168
1190
|
right: 0;
|
|
1169
1191
|
z-index: 60;
|
|
1170
|
-
top
|
|
1171
|
-
|
|
1192
|
+
/* 与顶栏里那颗汉堡同一个纵向基线:顶栏 padding-top = 12 + safe-top,行高 40 */
|
|
1193
|
+
top: calc(12px + var(--dsh-safe-top, 0px));
|
|
1194
|
+
height: 40px;
|
|
1172
1195
|
margin: 0;
|
|
1173
1196
|
padding: 0 64px;
|
|
1174
1197
|
align-items: center;
|
|
@@ -1193,7 +1216,12 @@ window.__ModuleLoader__.load({
|
|
|
1193
1216
|
.uV2eYG_row { padding: 6px 10px 10px !important; }
|
|
1194
1217
|
|
|
1195
1218
|
/* 抽屉:工作区列表置顶,面板入口退到列表下面,删掉品牌行与「新会话」行 */
|
|
1196
|
-
|
|
1219
|
+
/* 顶部按刘海高度留白,但那块就是页面自己的底色,看起来只是「留白」不是「切角」 */
|
|
1220
|
+
.hHd-Xa_root {
|
|
1221
|
+
display: flex;
|
|
1222
|
+
flex-direction: column;
|
|
1223
|
+
padding-top: calc(12px + var(--dsh-safe-top, 0px)) !important;
|
|
1224
|
+
}
|
|
1197
1225
|
.hHd-Xa_logoRow, .hHd-Xa_newSession { display: none !important; }
|
|
1198
1226
|
.hHd-Xa_regionArea { order: 1; }
|
|
1199
1227
|
/* 手机上的 DSH = 对话 + 改动 + 余额:只留「工作区 Git」,其余面板入口全收 */
|
|
@@ -1222,14 +1250,44 @@ window.__ModuleLoader__.load({
|
|
|
1222
1250
|
html.dsh-search-open .bhn1Oq_searchInput { height: 38px !important; }
|
|
1223
1251
|
|
|
1224
1252
|
/* 点遮罩关抽屉:原生窄屏没有这层,补一个(抽屉之下、正文之上) */
|
|
1225
|
-
|
|
1226
|
-
|
|
1253
|
+
/* 遮罩常驻,用透明度过渡:display 切换会瞬变,看着很硬 */
|
|
1254
|
+
.dsh-mobile-scrim {
|
|
1227
1255
|
position: fixed;
|
|
1228
1256
|
inset: 0;
|
|
1229
1257
|
z-index: 899;
|
|
1230
|
-
|
|
1258
|
+
display: block;
|
|
1259
|
+
opacity: 0;
|
|
1260
|
+
pointer-events: none;
|
|
1261
|
+
/* 纯黑底上「压暗」看不出层级,这里反过来提亮:抽屉保持纯黑,其余区域变灰 */
|
|
1262
|
+
background: rgba(255, 255, 255, .16);
|
|
1263
|
+
transition: opacity 160ms cubic-bezier(.2, .8, .2, 1);
|
|
1264
|
+
}
|
|
1265
|
+
html.dsh-drawer-open .dsh-mobile-scrim {
|
|
1266
|
+
opacity: 1;
|
|
1267
|
+
pointer-events: auto;
|
|
1268
|
+
/* 只在真正要动画时才提成合成层,别让它一直挂着 */
|
|
1269
|
+
will-change: opacity;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/* 微交互:按压/悬停给 120ms 过渡,别让反馈是瞬变 */
|
|
1273
|
+
.dsh-mobile-hamburger, .dsh-mobile-newchat, .dsh-mobile-clear-workspace,
|
|
1274
|
+
.dsh-code-copy, .dsh-mobile-offline {
|
|
1275
|
+
transition: background-color 120ms ease-out, color 120ms ease-out,
|
|
1276
|
+
opacity 120ms ease-out, transform 120ms ease-out;
|
|
1277
|
+
-webkit-tap-highlight-color: transparent;
|
|
1278
|
+
}
|
|
1279
|
+
.dsh-mobile-hamburger:active, .dsh-mobile-newchat:active,
|
|
1280
|
+
.dsh-mobile-clear-workspace:active, .dsh-code-copy:active {
|
|
1281
|
+
transform: scale(.94);
|
|
1282
|
+
}
|
|
1283
|
+
@keyframes dsh-pill-in {
|
|
1284
|
+
from { opacity: 0; transform: translate(-50%, -4px); }
|
|
1285
|
+
to { opacity: 1; transform: translate(-50%, 0); }
|
|
1286
|
+
}
|
|
1287
|
+
.dsh-mobile-offline { animation: dsh-pill-in 160ms cubic-bezier(.2, .8, .2, 1); }
|
|
1288
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1289
|
+
.dsh-mobile-scrim, .dsh-mobile-offline { transition: none; animation: none; }
|
|
1231
1290
|
}
|
|
1232
|
-
.dsh-mobile-scrim { display: none; }
|
|
1233
1291
|
|
|
1234
1292
|
/* 会话内容:代码块 / 表格 / 图片 / 附件在手机上不撑破页面 */
|
|
1235
1293
|
.md-code-block { max-width: 100%; }
|
|
@@ -1270,7 +1328,7 @@ window.__ModuleLoader__.load({
|
|
|
1270
1328
|
.dsh-code-copy[data-done="true"] { opacity: .6; }
|
|
1271
1329
|
.dsh-code-copy--float {
|
|
1272
1330
|
position: absolute;
|
|
1273
|
-
top: 6px;
|
|
1331
|
+
top: calc(6px + var(--dsh-safe-top, 0px));
|
|
1274
1332
|
right: 6px;
|
|
1275
1333
|
z-index: 2;
|
|
1276
1334
|
background: var(--dsw-alias-bg-base, rgba(0, 0, 0, .4));
|
|
@@ -1295,22 +1353,31 @@ window.__ModuleLoader__.load({
|
|
|
1295
1353
|
|
|
1296
1354
|
/* 手机外壳:深色配色(浅色主题保持原生配色,只换骨架) */
|
|
1297
1355
|
@media (max-width: 1023px) {
|
|
1298
|
-
|
|
1356
|
+
/* 纯黑底:连 DSH 自己的深色面(.wSkVaW_root / emptyTabHost / 抽屉)一起刷,
|
|
1357
|
+
否则空白页黑、会话页 #151517,开关抽屉会看到底色跳变。
|
|
1358
|
+
抽屉保持纯黑,靠遮罩提亮来区分层级。 */
|
|
1359
|
+
body[data-ds-dark-theme],
|
|
1360
|
+
body[data-ds-dark-theme] .wSkVaW_root,
|
|
1361
|
+
body[data-ds-dark-theme] [class*="_emptyTabHost"],
|
|
1299
1362
|
body[data-ds-dark-theme] [class*="_frame"],
|
|
1300
|
-
body[data-ds-dark-theme] [class*="_centerCol"]
|
|
1301
|
-
body[data-ds-dark-theme] [class*="
|
|
1363
|
+
body[data-ds-dark-theme] [class*="_centerCol"],
|
|
1364
|
+
body[data-ds-dark-theme] [class*="_scrollBody"],
|
|
1365
|
+
body[data-ds-dark-theme] [class*="_sidebarCol"],
|
|
1366
|
+
body[data-ds-dark-theme] .hHd-Xa_root,
|
|
1367
|
+
body[data-ds-dark-theme] .hHd-Xa_regionArea,
|
|
1368
|
+
body[data-ds-dark-theme] .hHd-Xa_panelList {
|
|
1302
1369
|
background: #000 !important;
|
|
1370
|
+
}
|
|
1371
|
+
body[data-ds-dark-theme] [class*="_sidebarCol"] {
|
|
1303
1372
|
border: 0 !important;
|
|
1304
|
-
box-shadow:
|
|
1373
|
+
box-shadow: none;
|
|
1305
1374
|
}
|
|
1306
|
-
body[data-ds-dark-theme] .hHd-Xa_root { background: #000 !important; }
|
|
1307
1375
|
body[data-ds-dark-theme] .wSkVaW_header { border-bottom: 0 !important; background: transparent !important; }
|
|
1308
1376
|
body[data-ds-dark-theme] .uV2eYG_card {
|
|
1309
|
-
background: #141416 !important;
|
|
1310
1377
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06) !important;
|
|
1311
1378
|
}
|
|
1312
1379
|
body[data-ds-dark-theme] .pXSMma_workspace { background: rgba(255, 255, 255, .07) !important; }
|
|
1313
|
-
/* 列表底部的滚动渐隐原本渐到 #1b1b1c
|
|
1380
|
+
/* 列表底部的滚动渐隐原本渐到 #1b1b1c,跟统一底色对齐 */
|
|
1314
1381
|
body[data-ds-dark-theme] .bhn1Oq_fade {
|
|
1315
1382
|
background-image: linear-gradient(rgba(0, 0, 0, 0), #000) !important;
|
|
1316
1383
|
}
|
|
@@ -1322,7 +1389,7 @@ window.__ModuleLoader__.load({
|
|
|
1322
1389
|
/* 断线胶囊:连续两次探活失败才出现,恢复即消失(任何宽度都显示) */
|
|
1323
1390
|
.dsh-mobile-offline {
|
|
1324
1391
|
position: fixed;
|
|
1325
|
-
top: calc(
|
|
1392
|
+
top: calc(6px + var(--dsh-safe-top, 0px));
|
|
1326
1393
|
left: 50%;
|
|
1327
1394
|
transform: translateX(-50%);
|
|
1328
1395
|
z-index: 80;
|
|
@@ -1388,21 +1455,21 @@ window.__ModuleLoader__.load({
|
|
|
1388
1455
|
overflow-y: auto;
|
|
1389
1456
|
-webkit-overflow-scrolling: touch;
|
|
1390
1457
|
overscroll-behavior: contain;
|
|
1391
|
-
padding-bottom: env(safe-area-inset-bottom, 0px) !important;
|
|
1458
|
+
padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px)) !important;
|
|
1392
1459
|
}
|
|
1393
1460
|
|
|
1394
|
-
/*
|
|
1395
|
-
DSH 不虚拟化消息,几百轮之后节点全在文档里;content-visibility 让浏览器跳过离屏块,
|
|
1396
|
-
滚动时只画视口附近的内容。只在窄屏生效,桌面保持原样。 */
|
|
1461
|
+
/* 长会话滚动:只在窄屏做滚动容器层面的优化,桌面保持原样 */
|
|
1397
1462
|
@media (max-width: 1023px) {
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
}
|
|
1463
|
+
/* 曾经给 markdown 块加 content-visibility:auto 想省渲染,
|
|
1464
|
+
但 4x CPU 节流实测滚动会卡出 155ms 的长帧(块进入视口时要重新布局),
|
|
1465
|
+
手机上的「卡顿」就是它。这里去掉,只保留滚动容器本身的优化。 */
|
|
1402
1466
|
[class*="_scrollBody"] {
|
|
1403
1467
|
overscroll-behavior: contain;
|
|
1404
1468
|
-webkit-overflow-scrolling: touch;
|
|
1405
1469
|
}
|
|
1470
|
+
/* 点按不要留系统高亮块(各 ROM 的 WebView 默认会给一层灰/蓝色块) */
|
|
1471
|
+
* { -webkit-tap-highlight-color: transparent; }
|
|
1472
|
+
*:focus:not(:focus-visible) { outline: none; }
|
|
1406
1473
|
}
|
|
1407
1474
|
|
|
1408
1475
|
`
|
|
@@ -1444,11 +1511,22 @@ window.__ModuleLoader__.load({
|
|
|
1444
1511
|
clear.dataset.none = String(isEmpty())
|
|
1445
1512
|
row.appendChild(clear)
|
|
1446
1513
|
}
|
|
1447
|
-
|
|
1514
|
+
// 流式回答时 DOM 每秒要变很多次,ensure 里是一次全量 querySelector,
|
|
1515
|
+
// 必须合并到每帧最多一次,否则主线程全被它占了(手机上就是掉帧)
|
|
1516
|
+
let frame = 0
|
|
1517
|
+
const schedule = () => {
|
|
1518
|
+
if (frame !== 0) return
|
|
1519
|
+
frame = requestAnimationFrame(() => {
|
|
1520
|
+
frame = 0
|
|
1521
|
+
ensure()
|
|
1522
|
+
})
|
|
1523
|
+
}
|
|
1524
|
+
const observer = new MutationObserver(schedule)
|
|
1448
1525
|
observer.observe(document.body, { subtree: true, childList: true })
|
|
1449
1526
|
ensure()
|
|
1450
1527
|
return () => {
|
|
1451
1528
|
observer.disconnect()
|
|
1529
|
+
if (frame !== 0) cancelAnimationFrame(frame)
|
|
1452
1530
|
document.querySelector('.dsh-mobile-clear-workspace')?.remove()
|
|
1453
1531
|
}
|
|
1454
1532
|
}
|
|
@@ -1465,7 +1543,15 @@ window.__ModuleLoader__.load({
|
|
|
1465
1543
|
const onPop = () => {
|
|
1466
1544
|
if (isOpen()) layout?.toggleSidebar?.()
|
|
1467
1545
|
}
|
|
1546
|
+
let frame = 0
|
|
1468
1547
|
const observer = new MutationObserver(() => {
|
|
1548
|
+
if (frame !== 0) return
|
|
1549
|
+
frame = requestAnimationFrame(() => {
|
|
1550
|
+
frame = 0
|
|
1551
|
+
check()
|
|
1552
|
+
})
|
|
1553
|
+
})
|
|
1554
|
+
const check = () => {
|
|
1469
1555
|
if (query && !query.matches) return
|
|
1470
1556
|
const open = isOpen()
|
|
1471
1557
|
if (open && !pushed) {
|
|
@@ -1479,7 +1565,7 @@ window.__ModuleLoader__.load({
|
|
|
1479
1565
|
pushed = false
|
|
1480
1566
|
if (history.state !== null && history.state.dshDrawer === true) history.back()
|
|
1481
1567
|
}
|
|
1482
|
-
}
|
|
1568
|
+
}
|
|
1483
1569
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
1484
1570
|
window.addEventListener('popstate', onPop)
|
|
1485
1571
|
return () => {
|
|
@@ -1488,6 +1574,155 @@ window.__ModuleLoader__.load({
|
|
|
1488
1574
|
}
|
|
1489
1575
|
}
|
|
1490
1576
|
|
|
1577
|
+
/**
|
|
1578
|
+
* 真机性能探针:URL 带 `?dsh-fps=1` 时在左下角显示帧率 / 最慢帧 / 长任务数。
|
|
1579
|
+
*
|
|
1580
|
+
* 只是为了在手机上定位「哪一步卡」,平时不加载任何逻辑(默认直接 return)。
|
|
1581
|
+
*/
|
|
1582
|
+
function installFpsMeter() {
|
|
1583
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}
|
|
1584
|
+
let enabled = /[?&]dsh-fps=1(&|$)/.test(window.location.search)
|
|
1585
|
+
if (!enabled) {
|
|
1586
|
+
try {
|
|
1587
|
+
enabled = window.sessionStorage.getItem('dsh-fps') === '1'
|
|
1588
|
+
} catch {
|
|
1589
|
+
/* 隐私模式禁 storage,忽略 */
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if (!enabled) return () => {}
|
|
1593
|
+
|
|
1594
|
+
const box = document.createElement('div')
|
|
1595
|
+
box.style.cssText =
|
|
1596
|
+
'position:fixed;left:8px;bottom:8px;z-index:99999;padding:4px 8px;border-radius:8px;' +
|
|
1597
|
+
'background:rgba(0,0,0,.72);color:#7ef29b;font:11px/1.5 ui-monospace,monospace;' +
|
|
1598
|
+
'pointer-events:none;white-space:pre;'
|
|
1599
|
+
document.body.appendChild(box)
|
|
1600
|
+
|
|
1601
|
+
let frames = 0
|
|
1602
|
+
let since = performance.now()
|
|
1603
|
+
let worst = 0
|
|
1604
|
+
let longTasks = 0
|
|
1605
|
+
let fps = 0
|
|
1606
|
+
let observer = null
|
|
1607
|
+
try {
|
|
1608
|
+
observer = new PerformanceObserver((list) => {
|
|
1609
|
+
for (const entry of list.getEntries()) if (entry.duration > 50) longTasks += 1
|
|
1610
|
+
})
|
|
1611
|
+
observer.observe({ entryTypes: ['longtask'] })
|
|
1612
|
+
} catch {
|
|
1613
|
+
/* 老内核没有 longtask,忽略 */
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const paint = () => {
|
|
1617
|
+
box.textContent = `${fps}fps worst ${Math.round(worst)}ms long ${longTasks}`
|
|
1618
|
+
worst = 0
|
|
1619
|
+
}
|
|
1620
|
+
let raf = 0
|
|
1621
|
+
const tick = (now) => {
|
|
1622
|
+
frames += 1
|
|
1623
|
+
worst = Math.max(worst, now - since)
|
|
1624
|
+
const elapsed = now - since
|
|
1625
|
+
if (elapsed >= 500) {
|
|
1626
|
+
fps = Math.round((frames * 1000) / elapsed)
|
|
1627
|
+
frames = 0
|
|
1628
|
+
since = now
|
|
1629
|
+
paint()
|
|
1630
|
+
}
|
|
1631
|
+
raf = requestAnimationFrame(tick)
|
|
1632
|
+
}
|
|
1633
|
+
raf = requestAnimationFrame(tick)
|
|
1634
|
+
window.setInterval(paint, 1000)
|
|
1635
|
+
return () => {
|
|
1636
|
+
cancelAnimationFrame(raf)
|
|
1637
|
+
observer?.disconnect()
|
|
1638
|
+
box.remove()
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* 切换工作区后别自动聚焦输入框。
|
|
1644
|
+
*
|
|
1645
|
+
* DSH 打开工作区选择器时会先把焦点交给搜索框;关掉后焦点会回到输入条,
|
|
1646
|
+
* 手机上就是键盘自己弹出来。这里在选择器关闭、或 chip 文案变化后,
|
|
1647
|
+
* 把焦点从输入条上撤走(只撤输入条里的可编辑区,不动选择器自己的搜索框)。
|
|
1648
|
+
*/
|
|
1649
|
+
function installComposerFocusGuard() {
|
|
1650
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {}
|
|
1651
|
+
let labelObserver = null
|
|
1652
|
+
let observedLabel = null
|
|
1653
|
+
let lastLabel = ''
|
|
1654
|
+
|
|
1655
|
+
const isComposer = (el) =>
|
|
1656
|
+
el !== null &&
|
|
1657
|
+
el !== document.body &&
|
|
1658
|
+
(el.isContentEditable === true || el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') &&
|
|
1659
|
+
(el.closest('.uV2eYG_root') !== null || el.closest('[class*="_composer"]') !== null)
|
|
1660
|
+
|
|
1661
|
+
const release = () => {
|
|
1662
|
+
const el = document.activeElement
|
|
1663
|
+
if (isComposer(el)) el.blur()
|
|
1664
|
+
}
|
|
1665
|
+
// DSH 可能在我们之后又把焦点塞回去,所以分几拍各撤一次
|
|
1666
|
+
const scheduleRelease = () => {
|
|
1667
|
+
for (const delay of [0, 80, 200, 400]) window.setTimeout(release, delay)
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const watchLabel = () => {
|
|
1671
|
+
const label = document.querySelector('[class*="_workspaceLabel"]')
|
|
1672
|
+
if (label === null || label === observedLabel) return
|
|
1673
|
+
labelObserver?.disconnect()
|
|
1674
|
+
observedLabel = label
|
|
1675
|
+
lastLabel = label.textContent ?? ''
|
|
1676
|
+
labelObserver = new MutationObserver(() => {
|
|
1677
|
+
const now = label.textContent ?? ''
|
|
1678
|
+
if (now === lastLabel) return
|
|
1679
|
+
lastLabel = now
|
|
1680
|
+
scheduleRelease()
|
|
1681
|
+
})
|
|
1682
|
+
labelObserver.observe(label, { childList: true, characterData: true, subtree: true })
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// 选择器关闭 = 里面那个搜索框失焦。用事件判断,别在每次 DOM 变动时全量 querySelector。
|
|
1686
|
+
const onFocusOut = (event) => {
|
|
1687
|
+
const target = event.target
|
|
1688
|
+
if (target instanceof Element && target.closest('[class*="wsc-"]') !== null) scheduleRelease()
|
|
1689
|
+
}
|
|
1690
|
+
document.addEventListener('focusout', onFocusOut, true)
|
|
1691
|
+
const ensureLabel = window.setInterval(watchLabel, 800)
|
|
1692
|
+
watchLabel()
|
|
1693
|
+
return () => {
|
|
1694
|
+
document.removeEventListener('focusout', onFocusOut, true)
|
|
1695
|
+
window.clearInterval(ensureLabel)
|
|
1696
|
+
labelObserver?.disconnect()
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* 外壳挂点自检:DSH 升级后这些 hash 类名可能全变,样式就整体失配。
|
|
1702
|
+
* 启动后检查一遍 DOM,把结果报给宿主,面板里能直接看到「外壳适配」这盏灯。
|
|
1703
|
+
*/
|
|
1704
|
+
function installShellContractReport() {
|
|
1705
|
+
if (typeof document === 'undefined' || typeof window === 'undefined') return () => {}
|
|
1706
|
+
// 只查「任何页面都该存在」的挂点;抽屉里的 hHd-Xa_ 只在打开时才有,不参与判定
|
|
1707
|
+
const markers = ['wSkVaW_', 'uV2eYG_', 'pXSMma_', 'pI_x6G_']
|
|
1708
|
+
let timer = window.setTimeout(async () => {
|
|
1709
|
+
try {
|
|
1710
|
+
const missing = markers.filter((marker) => document.querySelector(`[class*="${marker}"]`) === null)
|
|
1711
|
+
if (missing.length > 0) {
|
|
1712
|
+
console.warn('[remote-panel] 外壳挂点缺失,手机样式可能失配:', missing.join(', '))
|
|
1713
|
+
}
|
|
1714
|
+
await callApi('/shell', {
|
|
1715
|
+
method: 'POST',
|
|
1716
|
+
headers: { 'content-type': 'application/json' },
|
|
1717
|
+
body: JSON.stringify({ missing }),
|
|
1718
|
+
})
|
|
1719
|
+
} catch {
|
|
1720
|
+
/* 上报失败不影响使用 */
|
|
1721
|
+
}
|
|
1722
|
+
}, 1500)
|
|
1723
|
+
return () => window.clearTimeout(timer)
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1491
1726
|
/**
|
|
1492
1727
|
* 本机反馈:任务跑完 / 等你确认时,如果页面在后台,闪一下标题 + 震动。
|
|
1493
1728
|
* 不依赖推送服务(v1 不做推送),也不需要服务端配合。
|
|
@@ -1719,8 +1954,23 @@ window.__ModuleLoader__.load({
|
|
|
1719
1954
|
enhanceJobsBadge()
|
|
1720
1955
|
enhanceImages()
|
|
1721
1956
|
}
|
|
1722
|
-
const
|
|
1957
|
+
const RELEVANT = 'pre, img, table, [class*="job"], [class*="Job"], [class*="attach"]'
|
|
1958
|
+
const isRelevant = (records) => {
|
|
1959
|
+
for (const record of records) {
|
|
1960
|
+
const target = record.target
|
|
1961
|
+
if (target instanceof Element && target.closest(RELEVANT) !== null) return true
|
|
1962
|
+
for (const node of record.addedNodes) {
|
|
1963
|
+
if (node.nodeType !== 1) continue
|
|
1964
|
+
if (node.matches?.(RELEVANT) === true || node.querySelector?.(RELEVANT) !== null) return true
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
return false
|
|
1968
|
+
}
|
|
1969
|
+
const observer = new MutationObserver((records) => {
|
|
1723
1970
|
if (scheduled !== 0) return
|
|
1971
|
+
// 流式回答每帧都往消息里塞文本节点,全量扫描会把帧吃光;只有出现
|
|
1972
|
+
// 代码块/图片/表格/任务卡/附件这类节点时才真的需要补按钮
|
|
1973
|
+
if (!isRelevant(records)) return
|
|
1724
1974
|
scheduled = requestAnimationFrame(() => {
|
|
1725
1975
|
scheduled = 0
|
|
1726
1976
|
enhance()
|
|
@@ -1749,6 +1999,40 @@ window.__ModuleLoader__.load({
|
|
|
1749
1999
|
* 断线重连提示:DSH 前端没有「连接断开」的可见反馈,网络一抖页面只是不动了。
|
|
1750
2000
|
* 每 10s 打一次面板自己的 /status,连续两次失败才显示,恢复后自动消失。
|
|
1751
2001
|
*/
|
|
2002
|
+
/**
|
|
2003
|
+
* 点输入条上的「+」(添加文件或调用指令)时,别让输入框自动聚焦。
|
|
2004
|
+
* 手机上一聚焦就弹键盘,把刚打开的面板顶走。
|
|
2005
|
+
*
|
|
2006
|
+
* 做法:在 pointerdown 阶段记一个短窗口(900ms),窗口内如果输入框收到 focus 就立刻 blur 回去;
|
|
2007
|
+
* 点别的地方会立刻清掉窗口,不会影响正常输入。
|
|
2008
|
+
*/
|
|
2009
|
+
function installComposerFocusGuard() {
|
|
2010
|
+
if (typeof document === 'undefined') return () => {}
|
|
2011
|
+
let until = 0
|
|
2012
|
+
const isComposerInput = (el) => el instanceof Element && el.closest?.('.uV2eYG_input') !== null
|
|
2013
|
+
const onPointerDown = (event) => {
|
|
2014
|
+
const target = event.target
|
|
2015
|
+
until = target instanceof Element && target.closest('.uV2eYG_add') !== null ? Date.now() + 900 : 0
|
|
2016
|
+
}
|
|
2017
|
+
const onFocusIn = (event) => {
|
|
2018
|
+
if (Date.now() > until) return
|
|
2019
|
+
const el = event.target
|
|
2020
|
+
if (!isComposerInput(el)) return
|
|
2021
|
+
const drop = () => {
|
|
2022
|
+
if (Date.now() <= until && document.activeElement === el) el.blur?.()
|
|
2023
|
+
}
|
|
2024
|
+
drop()
|
|
2025
|
+
// 有的实现会在下一帧再 focus 一次,补一刀
|
|
2026
|
+
requestAnimationFrame(drop)
|
|
2027
|
+
}
|
|
2028
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
2029
|
+
document.addEventListener('focusin', onFocusIn, true)
|
|
2030
|
+
return () => {
|
|
2031
|
+
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
2032
|
+
document.removeEventListener('focusin', onFocusIn, true)
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
1752
2036
|
function installConnectivity() {
|
|
1753
2037
|
if (typeof window === 'undefined' || typeof fetch !== 'function') return () => {}
|
|
1754
2038
|
let failures = 0
|
|
@@ -1984,11 +2268,17 @@ window.__ModuleLoader__.load({
|
|
|
1984
2268
|
const cleanupAttention = installAttention()
|
|
1985
2269
|
const cleanupContent = installContentEnhancements()
|
|
1986
2270
|
const cleanupNet = installConnectivity()
|
|
2271
|
+
const cleanupFocus = installComposerFocusGuard()
|
|
2272
|
+
const cleanupFps = installFpsMeter()
|
|
2273
|
+
const cleanupContract = installShellContractReport()
|
|
1987
2274
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupClear, 'remote-panel: workspace clear')
|
|
1988
2275
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupBack, 'remote-panel: back gesture')
|
|
1989
2276
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupAttention, 'remote-panel: attention')
|
|
1990
2277
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupContent, 'remote-panel: content enhancements')
|
|
1991
2278
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupNet, 'remote-panel: connectivity')
|
|
2279
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupFocus, 'remote-panel: composer focus guard')
|
|
2280
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupFps, 'remote-panel: fps meter')
|
|
2281
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupContract, 'remote-panel: shell contract report')
|
|
1992
2282
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanup, 'remote-panel: mobile shell css')
|
|
1993
2283
|
|
|
1994
2284
|
ctx.slots.inject('shell.overlay', () =>
|
package/lib/contract.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外壳契约检查:确认 DSH 前端还在用我们依赖的那套 hash 类名。
|
|
3
|
+
*
|
|
4
|
+
* 手机外壳的样式全靠 DSH 构建产物里的私有类名(`wSkVaW_header` 这种),
|
|
5
|
+
* 它们随每次构建变化。DSH 一升级,样式可能整体失配——所以在宿主侧做一次
|
|
6
|
+
* 「挂点还在不在」的体检,失配时面板显红,而不是等用户发现界面错位。
|
|
7
|
+
*
|
|
8
|
+
* 只读:拉一次 index.html 和它引用的 CSS,做纯字符串匹配。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { request } from 'node:http'
|
|
12
|
+
|
|
13
|
+
/** 外壳依赖的类名前缀(对应 lib/client.js 里的选择器)。 */
|
|
14
|
+
export const SHELL_MARKERS = ['wSkVaW_', 'hHd-Xa_', 'uV2eYG_', 'pXSMma_', 'pI_x6G_']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ port: number, token: string, host?: string, timeoutMs?: number }} options
|
|
18
|
+
* @returns {Promise<{ ok: boolean, missing: string[], asset?: string, reason?: string, checkedAt: number }>}
|
|
19
|
+
*/
|
|
20
|
+
export async function checkShellContract({ port, token, host = '127.0.0.1', timeoutMs = 4000 }) {
|
|
21
|
+
const checkedAt = Date.now()
|
|
22
|
+
const base = `http://${host}:${port}`
|
|
23
|
+
// DSH 的令牌是「先 303 种 Cookie,再访问首页」两步走,这里手工跟一次
|
|
24
|
+
const login = await requestText(`${base}/?token=${encodeURIComponent(token)}`, { timeoutMs })
|
|
25
|
+
const cookie = login.cookie
|
|
26
|
+
if (cookie === undefined) return { ok: false, missing: [...SHELL_MARKERS], reason: 'login-failed', checkedAt }
|
|
27
|
+
const html = await requestText(`${base}/`, { timeoutMs, cookie })
|
|
28
|
+
if (html.body === undefined) return { ok: false, missing: [...SHELL_MARKERS], reason: 'index-unreachable', checkedAt }
|
|
29
|
+
// 页面通常引多个 CSS(vendor + 业务),挂点类名在业务那个里,所以全取回来一起看
|
|
30
|
+
const assets = [...html.body.matchAll(/href="\.?\/?(assets\/[^"']+\.css)"/g)].map((m) => m[1])
|
|
31
|
+
if (assets.length === 0) return { ok: false, missing: [...SHELL_MARKERS], reason: 'no-css-asset', checkedAt }
|
|
32
|
+
let css = ''
|
|
33
|
+
let matched
|
|
34
|
+
for (const asset of assets) {
|
|
35
|
+
const body = await requestText(`${base}/${asset}`, { timeoutMs, cookie })
|
|
36
|
+
if (body.body === undefined) continue
|
|
37
|
+
css += body.body
|
|
38
|
+
if (matched === undefined && SHELL_MARKERS.some((marker) => body.body.includes(marker))) matched = asset
|
|
39
|
+
}
|
|
40
|
+
if (css === '') return { ok: false, missing: [...SHELL_MARKERS], reason: 'css-unreachable', checkedAt }
|
|
41
|
+
const missing = SHELL_MARKERS.filter((marker) => !css.includes(marker))
|
|
42
|
+
return { ok: missing.length === 0, missing, asset: matched ?? assets[0], assets, checkedAt }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 简单 GET:返回 { body, cookie },超时/失败给 undefined。
|
|
47
|
+
* 体检不该把状态接口带崩,所以这里不抛异常。
|
|
48
|
+
*/
|
|
49
|
+
function requestText(url, { timeoutMs, cookie } = {}) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const headers = cookie === undefined ? {} : { cookie }
|
|
52
|
+
const req = request(url, { method: 'GET', headers, timeout: timeoutMs }, (res) => {
|
|
53
|
+
const jar = (res.headers['set-cookie'] ?? []).map((entry) => entry.split(';')[0]).join('; ')
|
|
54
|
+
if ((res.statusCode ?? 500) >= 400) {
|
|
55
|
+
res.resume()
|
|
56
|
+
resolve({ cookie: jar === '' ? undefined : jar })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
let body = ''
|
|
60
|
+
res.setEncoding('utf8')
|
|
61
|
+
res.on('data', (chunk) => {
|
|
62
|
+
body += chunk
|
|
63
|
+
})
|
|
64
|
+
res.on('end', () => resolve({ body, cookie: jar === '' ? undefined : jar }))
|
|
65
|
+
})
|
|
66
|
+
req.on('timeout', () => {
|
|
67
|
+
req.destroy()
|
|
68
|
+
resolve({})
|
|
69
|
+
})
|
|
70
|
+
req.on('error', () => resolve({}))
|
|
71
|
+
req.end()
|
|
72
|
+
})
|
|
73
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -110,6 +110,7 @@ export function syncAppAssets(dir = join(homedir(), '.dsh', 'remote-panel-app'))
|
|
|
110
110
|
/* 缺某个尺寸不算致命 */
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
+
writeLandingPage(dir)
|
|
113
114
|
const ca = localCaPath()
|
|
114
115
|
if (ca !== undefined) {
|
|
115
116
|
try {
|
|
@@ -131,21 +132,52 @@ export function syncAppAssets(dir = join(homedir(), '.dsh', 'remote-panel-app'))
|
|
|
131
132
|
* @param {string|undefined} token 当前进程的 DSH 令牌
|
|
132
133
|
* @param {string|undefined} accessKey 门禁口令
|
|
133
134
|
*/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
/**
|
|
136
|
+
* 写 `/remote-panel/login` 背后的跳转页(由 Caddy 放在门禁之后发送)。
|
|
137
|
+
*
|
|
138
|
+
* 之前这个页面放在免鉴权的 `/remote-panel/app/go.html` 里,等于把当前进程的令牌
|
|
139
|
+
* 暴露给网内任何人——口令门禁被绕过。现在令牌只在通过门禁之后才能拿到:
|
|
140
|
+
*
|
|
141
|
+
* 扫码 / 点链接 → /pair?k=口令(种 Cookie) → /remote-panel/login(要 Cookie) → /?token=
|
|
142
|
+
*
|
|
143
|
+
* @param {string} dir 资源目录
|
|
144
|
+
* @param {string|undefined} token 当前进程的 DSH 令牌
|
|
145
|
+
* @returns {string} 跳转目标
|
|
146
|
+
*/
|
|
147
|
+
export function writeLoginPage(dir, token) {
|
|
148
|
+
const target = token === undefined ? '/' : `/?token=${encodeURIComponent(token)}`
|
|
139
149
|
const html =
|
|
140
150
|
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
141
151
|
`<title>DSH</title><meta http-equiv="refresh" content="0;url=${target}">` +
|
|
142
152
|
'<style>html,body{height:100%;margin:0;background:#000;color:#888;font:14px system-ui;display:flex;align-items:center;justify-content:center}</style>' +
|
|
143
153
|
'<p>正在进入 DSH…</p>' +
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
// ?fps=1 时把开关记进 sessionStorage,跳转后由客户端插件读取(真机性能排查用)
|
|
155
|
+
'<script>try{if(/[?&]fps=1/.test(location.search))sessionStorage.setItem("dsh-fps","1")}catch(e){}' +
|
|
156
|
+
`location.replace(${JSON.stringify(target)})</script>`
|
|
157
|
+
writeFileSync(join(dir, 'login.html'), html)
|
|
146
158
|
return target
|
|
147
159
|
}
|
|
148
160
|
|
|
161
|
+
/**
|
|
162
|
+
* 旧版手机壳的入口页:不再带任何凭据,只提示升级。
|
|
163
|
+
*
|
|
164
|
+
* v0.2.x 的 APK 启动地址是这里;那些版本会把令牌写在本页里(安全缺陷),
|
|
165
|
+
* 所以直接停用,让页面告诉用户装新版壳。
|
|
166
|
+
*/
|
|
167
|
+
export function writeLandingPage(dir) {
|
|
168
|
+
const html =
|
|
169
|
+
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
170
|
+
'<title>DSH</title>' +
|
|
171
|
+
'<style>html,body{height:100%;margin:0;background:#000;color:#ddd;font:14px system-ui;display:flex;align-items:center;justify-content:center;padding:24px;text-align:center}</style>' +
|
|
172
|
+
'<div><p style="font-size:16px;font-weight:600;margin:0 0 8px">手机壳需要更新</p>' +
|
|
173
|
+
'<p style="color:#888;margin:0">旧版入口已停用(安全升级)。<br>请在浏览器打开面板重新下载 DSH 壳。</p></div>'
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(join(dir, 'go.html'), html)
|
|
176
|
+
} catch {
|
|
177
|
+
/* 目录不可写就算了 */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
149
181
|
/**
|
|
150
182
|
* 注入 index 的 head 行:主题色 + iOS 独立窗口 + 桌面图标 + 我们自己的 manifest。
|
|
151
183
|
*
|
|
@@ -337,7 +369,7 @@ routes.set('GET /pair', async ({ req }) => {
|
|
|
337
369
|
const scheme = requestScheme(req) === 'https' || detectTls() ? 'https' : 'http'
|
|
338
370
|
try {
|
|
339
371
|
const dir = join(homedir(), '.dsh', 'remote-panel-app')
|
|
340
|
-
if (existsSync(dir))
|
|
372
|
+
if (existsSync(dir)) writeLoginPage(dir, token)
|
|
341
373
|
} catch {
|
|
342
374
|
/* 目录还没建好就算了 */
|
|
343
375
|
}
|
|
@@ -453,6 +485,17 @@ routes.set('GET /probe', async ({ probe: runProbe = probe }) => {
|
|
|
453
485
|
return { ok: true, state, probe: await runProbe(state, { tls: detectTls() }) }
|
|
454
486
|
})
|
|
455
487
|
|
|
488
|
+
/** 客户端上报的外壳挂点自检结果(内存里保留最近一次)。 */
|
|
489
|
+
let lastShellReport = null
|
|
490
|
+
|
|
491
|
+
routes.set('POST /shell', async ({ req }) => {
|
|
492
|
+
const body = await readJson(req)
|
|
493
|
+
const missing = Array.isArray(body?.missing) ? body.missing.filter((item) => typeof item === 'string') : null
|
|
494
|
+
if (missing === null) throw new BadRequest('missing 必须是字符串数组')
|
|
495
|
+
lastShellReport = { ok: missing.length === 0, missing, checkedAt: Date.now() }
|
|
496
|
+
return { ok: true, ...lastShellReport }
|
|
497
|
+
})
|
|
498
|
+
|
|
456
499
|
routes.set('GET /status', ({ query }) => {
|
|
457
500
|
const tlsEnabled = detectTls()
|
|
458
501
|
return {
|
|
@@ -465,6 +508,8 @@ routes.set('GET /status', ({ query }) => {
|
|
|
465
508
|
scheme: tlsEnabled ? 'https' : 'http',
|
|
466
509
|
// 轻量证书信息:只读最近一次 /probe 的缓存,不在 status 里做网络探测
|
|
467
510
|
tls: tlsStatus(tlsEnabled, lastCertSnapshot()),
|
|
511
|
+
// 手机外壳的挂点自检:客户端启动后上报一次,用来说明「DSH 升级后样式还对不对得上」
|
|
512
|
+
shell: lastShellReport,
|
|
468
513
|
echo: query.get('echo') ?? null,
|
|
469
514
|
}
|
|
470
515
|
})
|
|
@@ -523,7 +568,7 @@ export function apply(ctx) {
|
|
|
523
568
|
const state = readState()
|
|
524
569
|
const ports = [Number(state?.port) || 8710, Number(state?.upstream) || 3080]
|
|
525
570
|
const tokens = await findTokens({ ports })
|
|
526
|
-
|
|
571
|
+
writeLoginPage(appDir, tokens.at(-1))
|
|
527
572
|
} catch (error) {
|
|
528
573
|
console.warn('[remote-panel] write go.html failed', error)
|
|
529
574
|
}
|
package/lib/pair.js
CHANGED
|
@@ -98,13 +98,13 @@ export function pairingUrls({ ip, port, accessKey, token, scheme = 'http' }) {
|
|
|
98
98
|
return {
|
|
99
99
|
gate,
|
|
100
100
|
token: tokenUrl,
|
|
101
|
-
//
|
|
101
|
+
// 登录入口:受门禁保护,Caddy 直接发一份带当前令牌的跳转页。
|
|
102
|
+
// 二维码只带门禁口令,口令换来的 Cookie 才能打开它——令牌不落在二维码/链接里。
|
|
103
|
+
login: `${base}/remote-panel/login`,
|
|
104
|
+
// 一张二维码走完两步:先种门禁 Cookie,再由 Caddy 转发到登录入口
|
|
102
105
|
// 走专用路径:Caddy 会回一个同源页面,由它发起跳转——
|
|
103
106
|
// 跨站导航不会携带 SameSite=Strict 的 DSH Cookie,非要同源页面自己发起才行
|
|
104
|
-
combo:
|
|
105
|
-
accessKey !== undefined && token !== undefined
|
|
106
|
-
? `${base}/pair?k=${accessKey}&token=${token}`
|
|
107
|
-
: undefined,
|
|
107
|
+
combo: accessKey !== undefined ? `${base}/pair?k=${accessKey}` : undefined,
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-panel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "DSH 远程访问控制面:部署向导、状态面板与一键配对(EasyTier + Caddy),数据面保持进程外。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"dsh": ">=0.2.1-rc.3"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
|
-
"test": "node --test \"test/*.test.js\""
|
|
36
|
+
"test": "node --test \"test/*.test.js\"",
|
|
37
|
+
"e2e": "node test/e2e/shell.mjs"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"qrcode": "^1.5.4"
|