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 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@@
Binary file
Binary file
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
- setProbe((await callApi('/probe')).probe)
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 + env(safe-area-inset-top, 0px)) !important;
1081
- padding: calc(11px + env(safe-area-inset-top, 0px)) 60px 0 10px !important;
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(11px + env(safe-area-inset-top, 0px)) !important; }
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: env(safe-area-inset-top, 0px);
1171
- height: 52px;
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
- .hHd-Xa_root { display: flex; flex-direction: column; padding-top: env(safe-area-inset-top, 0px) !important; }
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
- html.dsh-drawer-open .dsh-mobile-scrim {
1226
- display: block;
1253
+ /* 遮罩常驻,用透明度过渡:display 切换会瞬变,看着很硬 */
1254
+ .dsh-mobile-scrim {
1227
1255
  position: fixed;
1228
1256
  inset: 0;
1229
1257
  z-index: 899;
1230
- background: rgba(0, 0, 0, .35);
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
- body[data-ds-dark-theme] { background: #000 !important; }
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"] { background: #000 !important; }
1301
- body[data-ds-dark-theme] [class*="_sidebarCol"] {
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: 0 0 32px rgba(0, 0, 0, .45);
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(env(safe-area-inset-top, 0px) + 6px);
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
- /* 长会话滚动:离屏的 markdown 块不参与布局/绘制。
1395
- DSH 不虚拟化消息,几百轮之后节点全在文档里;content-visibility 让浏览器跳过离屏块,
1396
- 滚动时只画视口附近的内容。只在窄屏生效,桌面保持原样。 */
1461
+ /* 长会话滚动:只在窄屏做滚动容器层面的优化,桌面保持原样 */
1397
1462
  @media (max-width: 1023px) {
1398
- [class*="_markdown"] {
1399
- content-visibility: auto;
1400
- contain-intrinsic-size: auto 320px;
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
- const observer = new MutationObserver(ensure)
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 observer = new MutationObserver(() => {
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', () =>
@@ -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
- export function writeGoPage(dir, token, accessKey) {
135
- const target =
136
- token === undefined || !accessKey
137
- ? '/'
138
- : `/pair?k=${encodeURIComponent(accessKey)}&token=${encodeURIComponent(token)}`
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
- `<script>location.replace(${JSON.stringify(target)})</script>`
145
- writeFileSync(join(dir, 'go.html'), html)
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)) writeGoPage(dir, token, state.accessKey)
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
- writeGoPage(appDir, tokens.at(-1), state?.accessKey)
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
- // 一张二维码走完两步:先种门禁 Cookie,再由 Caddy 转发到令牌地址
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.2.2",
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"