dsh-in-hand 1.7.4 → 1.7.5

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
@@ -451,11 +451,22 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
451
451
 
452
452
  这就是手机页右上角那个双入口胶囊只能"事后切"、不能"事先测"的原因:
453
453
 
454
- - 胶囊上显示当前走的是哪条路(远端 / 局域网),点一下在 **自动 → 固定局域网 →
455
- 固定远端** 之间循环,选择记在 `localStorage`。
456
- - **自动**只用两个信号:这条路刚才通不通、你上次选了哪条。远端路上
457
- `GET /taskwatch/upstream-health` 体检失败(隧道或 DSH 挂了)就切局域网(若可用);
458
- 局域网路上请求失败(手机离开了 Wi-Fi)就切回远端。能用的那条路不会被主动换掉。
454
+ - 胶囊显示当前走的是哪条路(远端 / 局域网)。**点开是一张菜单**,列出两条入口:
455
+ 当前那条标「当前」,另一条标「切换」—— 点另一条 = 签一张票 + 跳一次。
456
+ - **没有任何自动跳转**(v1.7.5 起)。页面加载时只读一次入口清单、只渲染胶囊,
457
+ 不允许因为入口判断而导航;也没有定时体检。自动模式被删除的原因写在下面。
458
+ - 两道保险:跳转前在 `sessionStorage['tw-route-jump']` 写时间戳,8 秒内重复点击
459
+ 只给静态提示、不签票也不跳;URL 带 `?t=` 落地时立刻 `history.replaceState`
460
+ 清掉参数,并把入口锁成当前项(`sessionStorage['tw-route-lock']`)。
461
+
462
+ > **为什么不再"自动切"**:v1.6.0 – v1.7.4 的自动模式用「这条路刚才通不通」猜方向,
463
+ > 而 `apply()` 在初始加载与每 12 秒的体检后都会重新判断,判断出「该换」就改地址。
464
+ > 两条路的信号一旦互相矛盾(一条体检失败、另一条请求报错),页面就会在两个入口之间
465
+ > 无限往返,手机看到的是**地址栏来回变、整页反复刷新**。猜错的代价远大于省下的一次
466
+ > 点击,所以自动只保留在人的脑子里:菜单列出两条路,当前是哪条、另一条在哪,由用户点。
467
+ > `GET /taskwatch/upstream-health` 仍在中继侧保留,但页面不再请求它。
468
+
469
+ ![双入口菜单(390 宽,真实渲染)](docs/route-entries-390.png)
459
470
 
460
471
  ### 换路为什么不重输配对码
461
472
 
@@ -472,8 +483,8 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
472
483
  **v1.7.1 起,前两条插件宿主也自己实现了**(同一套语义):`GET /taskwatch/entries`
473
484
  回答「现在有哪几条路」,`POST /taskwatch/ticket` 给一张换路票据。
474
485
  所以从宿主页面(例如 `http://127.0.0.1:52676/taskwatch`)点「去远端」不再拿到 405 / 404。
475
- `GET /taskwatch/upstream-health` 仍只有中继实现;宿主页面拿到它的 404 时按「未知」处理,
476
- 并停掉探测 —— **不会**把「没有体检接口」误判成「这条路断了」。
486
+ `GET /taskwatch/upstream-health` 仍只有中继实现,但 **v1.7.5 起页面不再请求它**:
487
+ 自动体检切换已删除,来源也不可靠 —— 中继经隧道看到的来源永远是 `127.0.0.1`。
477
488
 
478
489
  #### 票据格式(v1.7.2)
479
490
 
@@ -518,8 +529,8 @@ nonce = crypto.randomBytes(16) # base64url 后 22 个字符
518
529
 
519
530
  页面侧遇到这两条接口的 **404 / 405** 会进入**静态降级**:不重试、不弹错,胶囊按
520
531
  「只有一个入口」隐藏;设置 / 任务详情里留一行说明与一个手动「去远端」链接
521
- (上次成功的地址记在 `localStorage['tw-remote-url']`)。500 / 502 / 网络错误只提示一次
522
- (同一句 30 秒内不重复弹),不会变成重试风暴。
532
+ (上次成功的地址记在 `localStorage['tw-remote-url']`)。其它错误只在入口菜单里
533
+ 显示一行静态提示(切菜单即清),不会自动重试,更不会因此导航。
523
534
 
524
535
  > **升级后公网换路仍是 401?** 先看 3099 上的中继进程是不是新代码:它在**启动时**
525
536
  > 一次性加载路由与票据密钥,不会热更新。带令牌调一次 `POST /taskwatch/ticket`,
package/lib/page.html CHANGED
@@ -4518,288 +4518,510 @@ if ('serviceWorker' in navigator) {
4518
4518
  }
4519
4519
  </script>
4520
4520
  <script>
4521
- /* ── 远端 / 局域网 双入口切换(2026-10-12)─────────────────────────────────
4522
- 为什么"自动"只能这么写:浏览器禁止 https 页面探测 http:// 的局域网地址(混合内容,
4523
- IP 主机连"可升级"都不算),而中继经 SSH 反向隧道看到的来源永远是 127.0.0.1,
4524
- 也判断不出手机在哪个网络。所以能用的信号只有两个:**这条路刚才通不通**,以及
4525
- **你上次选了哪条**。据此:
4526
- · 在远端这条路上,上游体检失败(隧道或 DSH 挂了)→ 自动切到局域网(若可用)
4527
- · 在局域网这条路上,请求自己失败(手机离开 Wi-Fi)→ 自动切回远端
4528
- · 点一下按钮可在 自动 → 固定局域网 → 固定远端 之间循环,选择会被记住
4529
- 换路不需要重输 6 位配对码:向中继要一张 60 秒、一次性、换完即作废的凭据。
4530
- 整段是独立 IIFE,任何异常只让它自己不显示,不影响页面其它功能。 */
4521
+ /* ROUTE-SAFETY-BEGIN
4522
+ ── 双入口:清单只读;换路只能由用户点选触发(2026-10-21 v1.7.5)──────────
4523
+
4524
+ 为什么以前会反复刷新:v1.6.0 起,页面把「该走哪条路」的判断写进 apply(),
4525
+ 而 apply() 不只在点按钮时调用 —— 初始加载读完入口清单会调一次,每 12 秒的
4526
+ 上游体检回来还会调一次。判断出「该换」就直接改地址跳走。两条路对「这条路
4527
+ 通不通」的判断一旦不一致(一条体检失败、另一条请求报错),A→B→A 就会无限
4528
+ 往返,手机看到的就是地址栏来回变、页面反复刷新。
4529
+
4530
+ v1.7.5 把这条自动路径整个删掉,只保留三件事:
4531
+ 1. 读一次 GET /taskwatch/entries,把两条路列进胶囊菜单 —— 只渲染,不导航;
4532
+ 2. 用户点另一个入口 → 向中继签一张换路票据 → 跳一次;整个脚本只有这一处
4533
+ 会改 location;
4534
+ 3. 跳之前写 sessionStorage 时间戳:8 秒内已有跳转标记就拒绝再跳,只给静态
4535
+ 提示(第二重保险)。
4536
+ 另外,带 ?t= 落地时立刻用 history.replaceState 清掉参数,并把入口锁成这
4537
+ 一项;锁定后它不再参与任何判断。这里没有定时器,也没有任何自动跳转。
4538
+
4539
+ 为什么自动模式被删掉:浏览器禁止 https 页面探测 http:// 的局域网地址,中继
4540
+ 经隧道看到的来源又永远是 127.0.0.1,页面拿不到「手机在哪个网络」这种事实。
4541
+ 用「刚才通不通」去猜,只能猜出方向,猜不出「该不该动」—— 猜错的代价是整页
4542
+ 反复刷新,远大于省下的一次点击。所以自动只保留在人的脑子里:胶囊列出两条路,
4543
+ 当前是哪条、另一条在哪,由用户自己点。
4544
+ */
4531
4545
  (function () {
4532
4546
  try {
4533
- var PREF_KEY = 'tw-route-pref'
4547
+ var LOCK_KEY = 'tw-route-lock'
4548
+ var JUMP_KEY = 'tw-route-jump'
4549
+ var JUMP_GUARD_MS = 8000
4534
4550
  var REMOTE_KEY = 'tw-remote-url'
4535
- var ORIGIN = 'unknown'
4536
- var REMOTE = ''
4537
- var LAN = []
4538
- var failed = false
4539
- var upstreamOk = true
4551
+
4552
+ var items = [] // [{kind,label,url}],顺序固定:远端在前
4553
+ var current = '' // 入口清单 / 地址判断出的当前入口
4554
+ var locked = '' // 票据落地后锁定的入口;锁定期间只信它
4555
+ var landed = false // 本次加载是不是票据落地
4556
+ var degraded = false // 宿主没有换路接口:静态降级,不再重试
4540
4557
  var busy = false
4541
- // 2026-10-21(v1.7.1):静态降级。宿主没有中继的 ticket / entries 接口时
4542
- // (拿到的就是 404 / 405),**永远不再重试**,也不弹错 —— 只是把胶囊按
4543
- // 「只有一个入口」隐藏,并在设置 / 详情里留一行说明与一个手动回跳链接。
4544
- var DEGRADED = false
4545
- var probeOff = false // 宿主没有上游体检:不知道 ≠ 失败,关掉探测别每 12 秒一个 404
4546
- var timer = 0 // 轮询句柄;降级 / 探测不可用时必须清掉
4547
- var failAt = 0 // 换路失败后的冷却起点,防止每次轮询都重试同一次失败
4548
- var toastMsg = ''
4549
- var toastAt = 0
4550
- var TOAST_COOLDOWN = 30 * 1000
4551
- var RETRY_COOLDOWN = 30 * 1000
4552
-
4553
- /* ROUTE-LOGIC-BEGIN */
4554
- function decideRoute(c) {
4555
- if (!c.hasRemote && !c.hasLan) return 'stay'
4556
- if (c.pref === 'remote') return c.origin === 'remote' ? 'stay' : (c.hasRemote ? 'toRemote' : 'stay')
4557
- if (c.pref === 'lan') return c.origin === 'lan' ? 'stay' : (c.hasLan ? 'toLan' : 'stay')
4558
- // auto:只在"这条路确实不行"时才动,绝不把能用的页面换成可能打不开的地址
4559
- if (c.origin === 'lan' && c.failed) return c.hasRemote ? 'toRemote' : 'stay'
4560
- if (c.origin === 'remote' && c.upstreamFailed) return c.hasLan ? 'toLan' : 'stay'
4561
- return 'stay'
4562
- }
4563
- /* ROUTE-LOGIC-END */
4558
+ var lastJumpAt = 0 // 同文档内的第二道保险(sessionStorage 不可用时仍有效)
4559
+ var pill = null
4560
+ var menu = null
4561
+ var scrim = null
4564
4562
 
4565
- function pref() {
4566
- try { var v = localStorage.getItem(PREF_KEY); return (v === 'lan' || v === 'remote') ? v : 'auto' }
4567
- catch (e) { return 'auto' }
4563
+ function readStore(store, key) {
4564
+ try { return store.getItem(key) || '' } catch (e) { return '' }
4565
+ }
4566
+ function writeStore(store, key, value) {
4567
+ try { store.setItem(key, value) } catch (e) {}
4568
+ }
4569
+ function dropStore(store, key) {
4570
+ try { store.removeItem(key) } catch (e) {}
4568
4571
  }
4569
- function setPref(v) { try { localStorage.setItem(PREF_KEY, v) } catch (e) {} }
4572
+ function readSession(key) { return readStore(sessionStorage, key) }
4573
+ function writeSession(key, value) { writeStore(sessionStorage, key, value) }
4570
4574
 
4571
- function readStore(key) { try { return localStorage.getItem(key) || '' } catch (e) { return '' } }
4572
- function writeStore(key, value) { try { localStorage.setItem(key, value) } catch (e) {} }
4575
+ function asKind(v) { return v === 'lan' || v === 'remote' ? v : '' }
4573
4576
 
4574
4577
  /** 上次成功切换 / 打开过的远端地址。只认 http(s),避免 localStorage 被第三方
4575
4578
  * 写进 javascript: 一类的东西后,这个链接变成一个可点的执行入口。 */
4576
4579
  function rememberedRemote() {
4577
- var value = readStore(REMOTE_KEY)
4580
+ var value = readStore(localStorage, REMOTE_KEY)
4578
4581
  return /^https?:\/\//i.test(value) ? value : ''
4579
4582
  }
4580
4583
  function rememberRemote(url) {
4581
4584
  if (!url) return
4582
4585
  var base = String(url).split('?')[0]
4583
- if (/^https?:\/\//i.test(base)) writeStore(REMOTE_KEY, base)
4586
+ if (/^https?:\/\//i.test(base)) writeStore(localStorage, REMOTE_KEY, base)
4584
4587
  }
4585
4588
 
4586
- /** 其它错误的提示:同一句 30 秒内只弹一次(404/405 的降级路径不弹)。 */
4587
- function toast(message) {
4588
- if (!message) return
4589
- var now = Date.now()
4590
- if (message === toastMsg && now - toastAt < TOAST_COOLDOWN) return
4591
- toastMsg = message
4592
- toastAt = now
4593
- var el = document.getElementById('tw-route-toast')
4594
- if (!el) {
4595
- el = document.createElement('div')
4596
- el.id = 'tw-route-toast'
4597
- el.setAttribute('role', 'status')
4598
- el.setAttribute('aria-live', 'polite')
4599
- document.body.appendChild(el)
4589
+ /** 把入口 URL 补成「…/taskwatch」。只做显示与同源判断;真正跳转用票据给的地址。 */
4590
+ function withRoute(url) {
4591
+ var s = String(url || '').trim()
4592
+ if (!/^https?:\/\//i.test(s)) return ''
4593
+ if (!/\/taskwatch\/?$/i.test(s)) s = s.replace(/\/+$/, '') + '/taskwatch'
4594
+ return s
4595
+ }
4596
+ function shortUrl(url) {
4597
+ try { return new URL(url, location.href).origin } catch (e) {
4598
+ return String(url || '').replace(/\/taskwatch\/?$/, '')
4600
4599
  }
4601
- el.textContent = message
4602
- el.className = 'on'
4603
- clearTimeout(el._hide)
4604
- el._hide = setTimeout(function () { el.className = '' }, 3200)
4600
+ }
4601
+ function originOf(url) {
4602
+ try { return new URL(url, location.href).origin } catch (e) { return '' }
4603
+ }
4604
+
4605
+ /** 票据落地:URL 上的 ?t= 立刻清掉,不留在地址栏、历史记录和分享出去的链接里。 */
4606
+ function stripTicketParam() {
4607
+ var u
4608
+ try { u = new URL(location.href) } catch (e) { return false }
4609
+ var params = u.searchParams
4610
+ if (!params.has('t') && !params.has('T')) return false
4611
+ params.delete('t')
4612
+ params.delete('T')
4613
+ var search = params.toString()
4614
+ var clean = u.pathname + (search ? '?' + search : '') + u.hash
4615
+ try { history.replaceState(null, '', clean) } catch (e) {}
4616
+ return true
4605
4617
  }
4606
4618
 
4607
- function stopTimer() {
4608
- if (timer) { clearInterval(timer); timer = 0 }
4619
+ function readJump() {
4620
+ var raw = readSession(JUMP_KEY)
4621
+ if (!raw) return null
4622
+ try {
4623
+ var j = JSON.parse(raw)
4624
+ return j && typeof j.at === 'number' ? j : null
4625
+ } catch (e) { return null }
4609
4626
  }
4610
- function startTimer() {
4611
- if (timer || DEGRADED || probeOff) return
4612
- timer = setInterval(function () { apply(); probe() }, 12000)
4627
+
4628
+ /** 8 秒护栏还差多久(毫秒)。0 = 现在可以跳。时钟回拨按「刚刚跳过」处理。 */
4629
+ function guardWait() {
4630
+ var now = Date.now()
4631
+ var wait = 0
4632
+ var j = readJump()
4633
+ if (j) {
4634
+ wait = j.at > now ? JUMP_GUARD_MS : Math.max(0, JUMP_GUARD_MS - (now - j.at))
4635
+ }
4636
+ if (lastJumpAt) {
4637
+ var mem = lastJumpAt > now ? JUMP_GUARD_MS : Math.max(0, JUMP_GUARD_MS - (now - lastJumpAt))
4638
+ if (mem > wait) wait = mem
4639
+ }
4640
+ return wait
4613
4641
  }
4614
4642
 
4615
- /** 降级:宿主没有换路接口。清掉轮询、隐藏胶囊、不报错。 */
4616
- function degrade() {
4617
- if (DEGRADED) return
4618
- DEGRADED = true
4619
- stopTimer()
4620
- ORIGIN = 'unknown'
4621
- apply()
4643
+ /** 换路的唯一出口:先过护栏、写带时间戳的标记,然后才跳。 */
4644
+ function navigateOnce(url, kind) {
4645
+ var wait = guardWait()
4646
+ if (wait > 0) {
4647
+ busy = false
4648
+ status('刚刚切换过,' + Math.ceil(wait / 1000) + ' 秒后才能再切')
4649
+ return false
4650
+ }
4651
+ var at = Date.now()
4652
+ lastJumpAt = at
4653
+ writeSession(JUMP_KEY, JSON.stringify({ at: at, to: kind }))
4654
+ location.href = url
4655
+ return true
4622
4656
  }
4623
4657
 
4624
- function isGone(status) { return status === 404 || status === 405 }
4658
+ function isGone(code) { return code === 404 || code === 405 }
4659
+
4660
+ function currentKind() {
4661
+ // 地址是事实;地址匹配不上(多网卡 / 服务端看到的 Host 不可信)才用票据锁。
4662
+ var here = guessCurrent()
4663
+ if (here) return here
4664
+ if (locked && hasKind(locked)) return locked
4665
+ return current
4666
+ }
4667
+ function hasKind(kind) {
4668
+ for (var i = 0; i < items.length; i++) if (items[i].kind === kind) return true
4669
+ return false
4670
+ }
4671
+ function itemOf(kind) {
4672
+ for (var i = 0; i < items.length; i++) if (items[i].kind === kind) return items[i]
4673
+ return null
4674
+ }
4675
+ function hasChoice() { return items.length >= 2 }
4625
4676
 
4626
4677
  /** 兼容两种 entries 形状:中继的 {remote, lan, current} 与新契约的
4627
4678
  * {entries:[{kind, label, url, current}]}。同一份页面在中继和宿主下都能跑。 */
4628
4679
  function parseEntries(j) {
4629
4680
  if (!j || j.ok !== true) return false
4630
- var list = Array.isArray(j.entries) ? j.entries : null
4631
- if (list) {
4632
- var rem = ''
4633
- var lan = []
4634
- var current = ''
4635
- for (var i = 0; i < list.length; i++) {
4636
- var e = list[i]
4637
- if (!e || typeof e.url !== 'string' || !/^https?:\/\//i.test(e.url)) continue
4638
- if (e.kind === 'remote') { if (!rem) rem = e.url }
4639
- else if (e.kind === 'lan') { if (lan.indexOf(e.url) < 0) lan.push(e.url) }
4640
- else continue
4641
- if (e.current === true) current = e.kind
4681
+ var list = []
4682
+ var declared = ''
4683
+ if (Array.isArray(j.entries)) {
4684
+ var seenKind = {}
4685
+ for (var i = 0; i < j.entries.length; i++) {
4686
+ var e = j.entries[i]
4687
+ if (!e || typeof e.url !== 'string') continue
4688
+ if (e.kind !== 'remote' && e.kind !== 'lan') continue
4689
+ if (seenKind[e.kind]) continue
4690
+ var url = withRoute(e.url)
4691
+ if (!url) continue
4692
+ seenKind[e.kind] = true
4693
+ list.push({
4694
+ kind: e.kind,
4695
+ label: typeof e.label === 'string' ? e.label : '',
4696
+ url: url,
4697
+ })
4698
+ if (e.current === true) declared = e.kind
4642
4699
  }
4643
- REMOTE = rem
4644
- LAN = lan
4645
- ORIGIN = current || (lan.length ? 'lan' : (rem ? 'remote' : 'unknown'))
4646
4700
  } else {
4647
- if (j.current !== 'lan' && j.current !== 'remote') return false
4648
- ORIGIN = j.current
4649
- REMOTE = typeof j.remote === 'string' ? j.remote : ''
4650
- LAN = Array.isArray(j.lan) ? j.lan.slice() : []
4701
+ var remote = withRoute(j.remote)
4702
+ if (remote) list.push({ kind: 'remote', label: '', url: remote })
4703
+ if (Array.isArray(j.lan)) {
4704
+ var seen = {}
4705
+ for (var k = 0; k < j.lan.length; k++) {
4706
+ var lanUrl = withRoute(j.lan[k])
4707
+ if (!lanUrl || seen[lanUrl]) continue
4708
+ seen[lanUrl] = true
4709
+ list.push({ kind: 'lan', label: '', url: lanUrl })
4710
+ break // 胶囊只需要一条局域网入口,多网卡不在这里堆
4711
+ }
4712
+ }
4713
+ declared = asKind(j.current)
4651
4714
  }
4652
- if (ORIGIN === 'remote') rememberRemote(location.origin + '/taskwatch')
4715
+ if (!list.length) return false
4716
+ // 远端永远排第一,局域网第二:顺序稳定,用户不必每次重新找。
4717
+ list.sort(function (a, b) {
4718
+ if (a.kind === b.kind) return 0
4719
+ return a.kind === 'remote' ? -1 : 1
4720
+ })
4721
+ items = list
4722
+ current = guessCurrent() || declared
4723
+ if (landed) {
4724
+ if (!locked) locked = current
4725
+ if (locked) writeSession(LOCK_KEY, locked)
4726
+ }
4727
+ if (current === 'remote') rememberRemote(location.origin + '/taskwatch')
4653
4728
  return true
4654
4729
  }
4655
4730
 
4731
+ /** 「我现在在哪条路上」优先按地址判断,不轻信服务端看到的 Host:
4732
+ * 中继经隧道看到的来源永远是 127.0.0.1,Host 判断可能落到别的分支。 */
4733
+ function guessCurrent() {
4734
+ var here = location.origin
4735
+ for (var i = 0; i < items.length; i++) {
4736
+ if (originOf(items[i].url) === here) return items[i].kind
4737
+ }
4738
+ return ''
4739
+ }
4740
+
4741
+ function status(message) {
4742
+ var el = menu && menu.querySelector('.tw-mstatus')
4743
+ if (!el) return
4744
+ el.textContent = message || ''
4745
+ }
4746
+
4747
+ /** 降级:宿主没有换路接口。清掉胶囊、不报错、不重试。 */
4748
+ function degrade() {
4749
+ if (degraded) return
4750
+ degraded = true
4751
+ closeMenu()
4752
+ if (pill) { pill.style.display = 'none'; pill.setAttribute('aria-hidden', 'true') }
4753
+ }
4754
+
4656
4755
  /** 降级态在设置 / 详情里的一行说明。宿主页面没有中继接口这件事本身不是错误,
4657
4756
  * 所以这里只陈述事实,并把「上次成功用过的远端地址」留成手动入口。 */
4658
4757
  function noteHtml() {
4659
- if (!DEGRADED) return ''
4758
+ if (!degraded) return ''
4660
4759
  var last = rememberedRemote()
4661
4760
  var text = '当前页面由插件宿主提供,远端切换需要中继接口'
4662
4761
  if (last) text += ';也可直接打开 <a href="' + esc(last) + '">' + esc(last) + '</a>'
4663
4762
  return '<div class="note route-note">' + text + '</div>'
4664
4763
  }
4764
+ function esc(s) {
4765
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
4766
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
4767
+ })
4768
+ }
4665
4769
  window.twRouteNote = noteHtml
4666
4770
 
4667
4771
  function mount() {
4668
- // 2026-10-21:胶囊必须待在**标题行**里(标题与连接三点之间)。
4669
- // 旧写法是 append 到 header 末尾、再靠 margin-left:auto 靠右,它于是成了
4670
- // 一个块级元素 —— 在旧截图里正好压在代码块顶部。现在它只是 .hrow 的
4671
- // 一个 flex 子项,不参与任何绝对/浮动定位,结构上不可能盖住内容。
4772
+ // 胶囊待的**标题行**里(标题与连接三点之间)。它只是 .hrow 的 flex 子项,
4773
+ // 不参与绝对/浮动定位,结构上不可能盖住内容。
4672
4774
  var host = document.getElementById('hrow') || document.querySelector('.hrow') || document.querySelector('header') || document.body
4673
4775
  var before = document.getElementById('netq') || document.getElementById('newBtn') || null
4674
- var el = document.getElementById('tw-route-pill')
4675
- if (!el) {
4676
- el = document.createElement('button')
4677
- el.id = 'tw-route-pill'
4678
- el.type = 'button'
4679
- el.setAttribute('aria-label', '切换远端 / 局域网连接')
4680
- el.addEventListener('click', function () {
4681
- var p = pref()
4682
- setPref(p === 'auto' ? 'lan' : (p === 'lan' ? 'remote' : 'auto'))
4683
- apply()
4684
- })
4776
+ if (!pill) {
4777
+ pill = document.createElement('button')
4778
+ pill.id = 'tw-route-pill'
4779
+ pill.type = 'button'
4780
+ pill.setAttribute('aria-haspopup', 'dialog')
4781
+ pill.setAttribute('aria-expanded', 'false')
4782
+ pill.setAttribute('aria-label', '连接入口')
4783
+ pill.addEventListener('click', openMenu)
4685
4784
  }
4686
- if (el.parentNode !== host || (before && el.nextSibling !== before)) {
4687
- if (before && before.parentNode === host) host.insertBefore(el, before)
4688
- else host.appendChild(el)
4785
+ if (pill.parentNode !== host || (before && pill.nextSibling !== before)) {
4786
+ if (before && before.parentNode === host) host.insertBefore(pill, before)
4787
+ else host.appendChild(pill)
4689
4788
  }
4690
- return el
4789
+ return pill
4691
4790
  }
4692
4791
 
4693
- /** 只有一个入口时不显示:此时没有「切到另一条路」这件事,
4694
- * 胶囊上的「固定 / 自动」只会让人以为有得选。 */
4695
- function hasChoice() {
4696
- return !!REMOTE && Array.isArray(LAN) && LAN.length > 0
4792
+ function buildMenu() {
4793
+ if (menu) return
4794
+ scrim = document.createElement('div')
4795
+ scrim.id = 'tw-route-scrim'
4796
+ scrim.setAttribute('aria-hidden', 'true')
4797
+ scrim.addEventListener('click', closeMenu)
4798
+ document.body.appendChild(scrim)
4799
+
4800
+ menu = document.createElement('div')
4801
+ menu.id = 'tw-route-menu'
4802
+ menu.setAttribute('role', 'dialog')
4803
+ menu.setAttribute('aria-label', '连接入口')
4804
+ var head = document.createElement('div')
4805
+ head.className = 'tw-mhead'
4806
+ head.textContent = '连接入口'
4807
+ var list = document.createElement('div')
4808
+ list.className = 'tw-mlist'
4809
+ var foot = document.createElement('div')
4810
+ foot.className = 'tw-mfoot'
4811
+ var statusEl = document.createElement('div')
4812
+ statusEl.className = 'tw-mstatus'
4813
+ statusEl.setAttribute('role', 'status')
4814
+ statusEl.setAttribute('aria-live', 'polite')
4815
+ menu.appendChild(head)
4816
+ menu.appendChild(list)
4817
+ menu.appendChild(foot)
4818
+ menu.appendChild(statusEl)
4819
+ document.body.appendChild(menu)
4820
+ document.addEventListener('keydown', onKey)
4697
4821
  }
4698
4822
 
4699
- function label() {
4700
- if (ORIGIN !== 'lan' && ORIGIN !== 'remote') return ''
4701
- if (!hasChoice()) return ''
4702
- var p = pref()
4703
- var here = ORIGIN === 'lan' ? '局域网' : '远端'
4704
- if (p === 'lan' && ORIGIN !== 'lan') return '去局域网'
4705
- if (p === 'remote' && ORIGIN !== 'remote') return '去远端'
4706
- return here + '·' + (p === 'auto' ? '自动' : '固定')
4823
+ function renderMenu() {
4824
+ if (!menu) return
4825
+ var list = menu.querySelector('.tw-mlist')
4826
+ while (list.firstChild) list.removeChild(list.firstChild)
4827
+ var here = currentKind()
4828
+ for (var i = 0; i < items.length; i++) {
4829
+ var it = items[i]
4830
+ var isHere = it.kind === here
4831
+ var row = document.createElement('button')
4832
+ row.type = 'button'
4833
+ row.className = 'tw-entry'
4834
+ row.setAttribute('data-kind', it.kind)
4835
+ row.setAttribute('aria-current', isHere ? 'true' : 'false')
4836
+ var kind = document.createElement('span')
4837
+ kind.className = 'tw-kind'
4838
+ kind.textContent = it.label || (it.kind === 'lan' ? '局域网' : '远端')
4839
+ var url = document.createElement('span')
4840
+ url.className = 'tw-url'
4841
+ url.textContent = shortUrl(it.url)
4842
+ var chip = document.createElement('span')
4843
+ chip.className = 'tw-chip'
4844
+ chip.textContent = isHere ? '当前' : '切换'
4845
+ row.appendChild(kind)
4846
+ row.appendChild(url)
4847
+ row.appendChild(chip)
4848
+ if (!isHere) row.addEventListener('click', onPick)
4849
+ list.appendChild(row)
4850
+ }
4851
+ var foot = menu.querySelector('.tw-mfoot')
4852
+ foot.textContent = locked && locked === here
4853
+ ? '已锁定为票据落地入口;页面不会自动切换,只在你点另一个入口时才跳一次。'
4854
+ : '只在你点选时切换;页面不会自动跳转。'
4707
4855
  }
4708
4856
 
4709
- function apply() {
4710
- var el = mount()
4711
- if (DEGRADED) { el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); return }
4712
- var text = label()
4713
- if (!text) { el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); return }
4714
- el.textContent = text
4715
- el.removeAttribute('aria-hidden')
4716
- el.setAttribute('aria-label', '切换远端 / 局域网连接,当前 ' + text)
4717
- el.style.display = 'inline-flex'
4718
- var decision = decideRoute({
4719
- origin: ORIGIN, pref: pref(), failed: failed, upstreamFailed: !upstreamOk,
4720
- hasLan: LAN.length > 0, hasRemote: !!REMOTE,
4721
- })
4722
- if (decision === 'stay' || busy) return
4723
- // 刚失败过就先停 30 秒:轮询每 12 秒一次,不设冷却就变成"点一次、失败、每 12 秒重试"。
4724
- if (Date.now() - failAt < RETRY_COOLDOWN) return
4857
+ function positionMenu() {
4858
+ if (!menu || !pill) return
4859
+ var r = pill.getBoundingClientRect()
4860
+ var top = Math.round(r.bottom + 6)
4861
+ if (top < 8) top = 8
4862
+ // 右对齐到胶囊,但**必须夹在视口内**:胶囊在标题行中间偏右,直接按它右对齐
4863
+ // 会把 330px 宽的菜单推到屏幕左侧外面(390 宽实测会裁掉入口名)。
4864
+ var margin = 12
4865
+ var width = menu.offsetWidth || Math.min(330, window.innerWidth - margin * 2)
4866
+ var right = Math.max(margin, Math.round(window.innerWidth - r.right))
4867
+ if (window.innerWidth - right - width < margin) {
4868
+ right = Math.max(margin, window.innerWidth - width - margin)
4869
+ }
4870
+ menu.style.top = top + 'px'
4871
+ menu.style.right = right + 'px'
4872
+ menu.style.left = 'auto'
4873
+ menu.style.maxHeight = Math.max(180, Math.round(window.innerHeight - top - margin)) + 'px'
4874
+ }
4875
+
4876
+ function openMenu() {
4877
+ if (degraded || !hasChoice()) return
4878
+ buildMenu()
4879
+ renderMenu()
4880
+ status('')
4881
+ positionMenu()
4882
+ scrim.classList.add('on')
4883
+ menu.classList.add('on')
4884
+ pill.setAttribute('aria-expanded', 'true')
4885
+ }
4886
+
4887
+ function closeMenu() {
4888
+ if (menu) menu.classList.remove('on')
4889
+ if (scrim) scrim.classList.remove('on')
4890
+ if (pill) pill.setAttribute('aria-expanded', 'false')
4891
+ }
4892
+
4893
+ function onKey(e) {
4894
+ if (e && e.key === 'Escape') closeMenu()
4895
+ }
4896
+
4897
+ function onPick() {
4898
+ jump(this.getAttribute('data-kind'))
4899
+ }
4900
+
4901
+ /** 用户点「另一个入口」:签一张票,跳一次。失败只改菜单里的静态提示。 */
4902
+ function jump(kind) {
4903
+ if (busy || degraded) return
4904
+ var target = itemOf(kind)
4905
+ if (!target) { status('这条入口现在不可用'); return }
4906
+ if (kind === currentKind()) { status('已经在这个入口上'); return }
4907
+ // 同源目标跳过去只是刷新一次页面,没有意义(也会绕开 8 秒护栏的直觉)。
4908
+ if (originOf(target.url) === location.origin) {
4909
+ status('这条入口和当前地址是同一个源,无需切换')
4910
+ return
4911
+ }
4912
+ // 护栏提前到签票之前:被拦下时连票都不该签。
4913
+ var wait = guardWait()
4914
+ if (wait > 0) {
4915
+ status('刚刚切换过,' + Math.ceil(wait / 1000) + ' 秒后才能再切')
4916
+ return
4917
+ }
4725
4918
  busy = true
4919
+ status('正在签票…')
4726
4920
  fetch('/taskwatch/ticket', {
4727
4921
  method: 'POST',
4728
4922
  headers: { 'content-type': 'application/json' },
4729
- body: JSON.stringify({ to: decision === 'toLan' ? 'lan' : 'remote' }),
4923
+ body: JSON.stringify({ to: kind }),
4730
4924
  }).then(function (r) {
4731
4925
  // 宿主没有换路接口(405)或这条路由不存在(404):静态降级,绝不重试。
4732
4926
  if (isGone(r.status)) { degrade(); return null }
4733
4927
  if (!r.ok) throw new Error('HTTP ' + r.status)
4734
4928
  return r.json()
4735
4929
  }).then(function (j) {
4736
- if (j === null) { busy = false; return }
4737
- if (j && j.ok && j.url) {
4738
- if (decision === 'toRemote') rememberRemote(j.url)
4739
- location.href = j.url
4930
+ if (j === null) return
4931
+ if (!j || j.ok !== true || typeof j.url !== 'string' || !/^https?:\/\//i.test(j.url)) {
4932
+ busy = false
4933
+ status((j && j.error) || '没有拿到换路地址')
4740
4934
  return
4741
4935
  }
4742
- busy = false
4743
- failAt = Date.now()
4744
- toast((j && j.error) || '无法切换连接方式')
4936
+ if (kind === 'remote') rememberRemote(j.url)
4937
+ navigateOnce(j.url, kind)
4745
4938
  }).catch(function (e) {
4746
4939
  busy = false
4747
- failAt = Date.now()
4748
- toast('切换连接方式失败:' + ((e && e.message) || '网络错误'))
4940
+ status('切换失败:' + ((e && e.message) || '网络错误'))
4749
4941
  })
4750
4942
  }
4751
4943
 
4752
- function probe() {
4753
- if (DEGRADED || probeOff) return
4754
- fetch('/taskwatch/upstream-health', { cache: 'no-store' })
4755
- .then(function (r) {
4756
- // 404 / 405 = 这个外壳没有上游体检(插件宿主)。拿它当"这条路断了"会误触发
4757
- // 一次到远端的切换,所以只关掉探测,不改失败判据。
4758
- if (isGone(r.status)) { probeOff = true; stopTimer(); return null }
4759
- if (!r.ok) throw new Error('HTTP ' + r.status)
4760
- return r.json()
4761
- })
4762
- .then(function (j) {
4763
- if (j === null) return
4764
- failed = false
4765
- upstreamOk = !!(j && j.ok)
4766
- apply()
4767
- })
4768
- .catch(function (e) {
4769
- failed = true
4770
- apply()
4771
- toast('状态检查失败:' + ((e && e.message) || '网络错误'))
4772
- })
4944
+ /** 只渲染,不判断、不跳。加载路径上调用它是安全的。 */
4945
+ function apply() {
4946
+ var el = mount()
4947
+ if (degraded) { el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); return }
4948
+ var here = currentKind()
4949
+ if (!hasChoice() || !here) { el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); return }
4950
+ var text = here === 'lan' ? '局域网' : '远端'
4951
+ while (el.firstChild) el.removeChild(el.firstChild)
4952
+ var label = document.createElement('span')
4953
+ label.textContent = text
4954
+ var caret = document.createElement('span')
4955
+ caret.className = 'tw-caret'
4956
+ caret.setAttribute('aria-hidden', 'true')
4957
+ el.appendChild(label)
4958
+ el.appendChild(caret)
4959
+ el.removeAttribute('aria-hidden')
4960
+ el.setAttribute('aria-label', '连接入口:' + text + '。点按查看另一条入口')
4961
+ el.style.display = 'inline-flex'
4773
4962
  }
4774
4963
 
4775
4964
  // 样式随手挂上,不依赖页面里已有的 class(两份外壳的样式表可能不同)
4776
4965
  var css = document.createElement('style')
4777
- /* 跟页面里其它胶囊同一套尺度:44 的命中区、999 的圆角、12.5px 的字、
4778
- 10 的内边距;颜色仍然只用 currentColor 混合(不依赖页面 class,
4779
- 万一被别的外壳复用,var() 取不到也不会塌成一块白底)。
4780
- 它现在是 .hrow 的 flex 子项:flex:0 0 auto、margin:0,不再用 margin-left:auto
4781
- 把自己推到 header 末尾成一行 —— 那正是旧截图里压住内容的原因。 */
4782
- css.textContent = '#tw-route-pill{display:none;align-items:center;gap:6px;'
4783
- + 'flex:0 0 auto;margin:0;max-width:36vw;overflow:hidden;text-overflow:ellipsis;'
4784
- + 'height:44px;min-height:44px;line-height:1;padding:0 8px;border-radius:999px;'
4785
- + 'font:600 12.5px/1.25 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",system-ui,sans-serif;'
4966
+ var FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",system-ui,sans-serif'
4967
+ css.textContent = '#tw-route-pill{display:none;align-items:center;gap:5px;'
4968
+ + 'flex:0 0 auto;margin:0;max-width:38vw;overflow:hidden;'
4969
+ + 'height:44px;min-height:44px;line-height:1;padding:0 10px;border-radius:999px;'
4970
+ + 'font:600 12.5px/1.25 ' + FONT + ';'
4786
4971
  + 'color:inherit;background:color-mix(in srgb,currentColor 8%,transparent);'
4787
4972
  + 'border:1px solid color-mix(in srgb,currentColor 24%,transparent);cursor:pointer;white-space:nowrap}'
4788
- + '#tw-route-pill[style*="inline-flex"]{display:inline-flex!important}'
4973
+ + '#tw-route-pill[aria-expanded="true"]{background:color-mix(in srgb,currentColor 15%,transparent)}'
4789
4974
  + '#tw-route-pill:active{transform:translateY(1px)}'
4790
- // 换路失败的一次性提示(同一错误 30 秒内不重复弹)。同样只用 currentColor 混合,
4791
- // 明暗两套都能读;max-width 留出两侧 12px,390 宽下不会出现横向滚动。
4792
- + '#tw-route-toast{position:fixed;left:50%;bottom:calc(96px + var(--safe-b,0px));'
4793
- + 'transform:translateX(-50%);z-index:90;max-width:calc(100vw - 24px);box-sizing:border-box;'
4794
- + 'padding:10px 14px;border-radius:12px;opacity:0;pointer-events:none;transition:opacity .18s;'
4795
- + 'font:600 12.5px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",system-ui,sans-serif;'
4796
- + 'color:inherit;background:color-mix(in srgb,currentColor 12%,transparent);'
4797
- + 'border:1px solid color-mix(in srgb,currentColor 22%,transparent);overflow-wrap:anywhere}'
4798
- + '#tw-route-toast.on{opacity:1}'
4799
- + '@media (prefers-reduced-motion:reduce){#tw-route-toast{transition:none}}'
4975
+ + '#tw-route-pill .tw-caret{flex:0 0 auto;width:0;height:0;opacity:.62;'
4976
+ + 'border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid currentColor}'
4977
+ // 点空白处关闭:一层透明遮罩,而不是给 document 挂 click(后者会误吞页面上的点击)。
4978
+ + '#tw-route-scrim{position:fixed;inset:0;z-index:70;background:rgba(8,10,14,.22);display:none}'
4979
+ + '#tw-route-scrim.on{display:block}'
4980
+ // 菜单:固定在胶囊下方右对齐;宽度跟着视口收,390 宽下不会顶到屏幕外。
4981
+ + '#tw-route-menu{position:fixed;z-index:80;display:none;box-sizing:border-box;'
4982
+ + 'width:min(330px,calc(100vw - 20px));padding:8px;overflow-y:auto;'
4983
+ + 'border-radius:16px;background:var(--panel,Canvas);color:inherit;'
4984
+ + 'border:1px solid color-mix(in srgb,currentColor 16%,transparent);'
4985
+ + 'box-shadow:0 18px 44px -12px rgba(0,0,0,.38),0 2px 8px rgba(0,0,0,.12);'
4986
+ + 'font:400 13px/1.4 ' + FONT + '}'
4987
+ + '#tw-route-menu.on{display:block}'
4988
+ + '#tw-route-menu .tw-mhead{padding:5px 10px 8px;font:600 11.5px/1 ' + FONT + ';'
4989
+ + 'letter-spacing:.06em;color:color-mix(in srgb,currentColor 48%,transparent)}'
4990
+ + '#tw-route-menu .tw-entry{display:flex;align-items:center;gap:8px;width:100%;'
4991
+ + 'min-height:56px;padding:8px 10px;border:0;border-radius:12px;background:none;'
4992
+ + 'color:inherit;text-align:left;cursor:pointer;font:inherit}'
4993
+ + '#tw-route-menu .tw-entry+.tw-entry{margin-top:2px}'
4994
+ + '#tw-route-menu .tw-kind{flex:0 0 auto;font-weight:600;font-size:13.5px}'
4995
+ + '#tw-route-menu .tw-url{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;'
4996
+ + 'white-space:nowrap;font-size:11.5px;color:color-mix(in srgb,currentColor 55%,transparent);'
4997
+ + 'font-variant-numeric:tabular-nums}'
4998
+ + '#tw-route-menu .tw-chip{flex:0 0 auto;padding:4px 8px;border-radius:999px;'
4999
+ + 'font-size:11.5px;font-weight:600;white-space:nowrap;'
5000
+ + 'border:1px solid color-mix(in srgb,currentColor 20%,transparent)}'
5001
+ + '#tw-route-menu .tw-entry[aria-current="true"]{background:color-mix(in srgb,currentColor 6%,transparent)}'
5002
+ + '#tw-route-menu .tw-entry[aria-current="true"] .tw-chip{background:color-mix(in srgb,currentColor 10%,transparent);border-color:transparent}'
5003
+ + '#tw-route-menu .tw-entry:not([aria-current="true"]) .tw-chip{color:var(--accent,#2f6df6)}'
5004
+ + '#tw-route-menu .tw-entry:not([aria-current="true"]):active{background:color-mix(in srgb,currentColor 10%,transparent)}'
5005
+ + '#tw-route-menu .tw-mfoot{padding:8px 10px 4px;font-size:11.5px;line-height:1.45;'
5006
+ + 'color:color-mix(in srgb,currentColor 52%,transparent)}'
5007
+ + '#tw-route-menu .tw-mstatus{padding:6px 10px 4px;font-size:11.5px;line-height:1.4;'
5008
+ + 'color:var(--warn,#a35b00)}'
5009
+ + '#tw-route-menu .tw-mstatus:empty{display:none}'
5010
+ + '@media (prefers-reduced-motion:reduce){#tw-route-pill:active{transform:none}}'
4800
5011
  document.head.appendChild(css)
4801
5012
 
4802
- // 入口清单只取一次:404/405 → 静态降级并不再重试;其它错误 → 提示一次后收手。
5013
+ // ?t= 落地:先把参数清掉并把入口锁成这一项,再去做任何别的事。
5014
+ landed = stripTicketParam()
5015
+ locked = asKind(readSession(LOCK_KEY))
5016
+ if (landed) {
5017
+ var jumped = readJump()
5018
+ var jumpKind = jumped ? asKind(jumped.to) : ''
5019
+ if (jumpKind) locked = jumpKind
5020
+ if (locked) writeSession(LOCK_KEY, locked)
5021
+ }
5022
+
5023
+ // 入口清单只取一次:404/405 → 静态降级并不再重试;其它错误 → 记一条日志后收手。
5024
+ // 这里只渲染。没有任何分支会改地址,所以初始加载路径上不可能发生导航。
4803
5025
  fetch('/taskwatch/entries', { cache: 'no-store' })
4804
5026
  .then(function (r) {
4805
5027
  if (isGone(r.status)) { degrade(); return null }
@@ -4808,16 +5030,15 @@ if ('serviceWorker' in navigator) {
4808
5030
  })
4809
5031
  .then(function (j) {
4810
5032
  if (j === null) return
4811
- if (!parseEntries(j)) return
5033
+ if (!parseEntries(j)) { console.warn('taskwatch: 入口清单形状不认识,双入口胶囊不显示'); return }
4812
5034
  apply()
4813
- probe()
4814
- if (!probeOff) startTimer()
4815
5035
  })
4816
5036
  .catch(function (e) {
4817
- toast('入口清单读取失败:' + ((e && e.message) || '网络错误'))
5037
+ console.warn('taskwatch: 入口清单读取失败', (e && e.message) || e)
4818
5038
  })
4819
5039
  } catch (e) { console.warn('taskwatch: 双入口切换未启用', e) }
4820
5040
  })()
5041
+ /* ROUTE-SAFETY-END */
4821
5042
  </script>
4822
5043
  </body>
4823
5044
  </html>
package/lib/sw.js CHANGED
@@ -44,7 +44,7 @@ const SHELL = ['/taskwatch', '/taskwatch/icon-192.png', '/taskwatch/icon-512.png
44
44
  // 那次重载是 cache-first 时代的补丁(旧外壳永远不会自己变新)。现在页面已经是
45
45
  // **网络优先**(见下面 fetch 里的注释),每次导航本来就取新版,这个补丁既没必要、
46
46
  // 又足以把整站打死。别再加回来。
47
- const CACHE = 'taskwatch-v14'
47
+ const CACHE = 'taskwatch-v15'
48
48
 
49
49
  const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-in-hand",
3
- "version": "1.7.4",
3
+ "version": "1.7.5",
4
4
  "description": "掌上 DSH:DeepSeek Harness 的手机端,不用装 App——会话、实时对话、交付文件、后台任务与待批准。 / DSH in hand: the phone client for DeepSeek Harness with no app to install.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -30,10 +30,11 @@
30
30
  "observability"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=20"
33
+ "node": ">=20",
34
+ "dsh": ">=0.2.1-rc.2 <0.3.0"
34
35
  },
35
36
  "scripts": {
36
- "check": "node scripts/check-bundle.mjs && node scripts/check-files.mjs && node scripts/check-uploads.mjs && node scripts/check-queue.mjs && node scripts/check-entries.mjs && node scripts/check-ticket-hmac.mjs && node scripts/check-contract.mjs",
37
+ "check": "node scripts/check-bundle.mjs && node scripts/check-files.mjs && node scripts/check-uploads.mjs && node scripts/check-queue.mjs && node scripts/check-entries.mjs && node scripts/check-ticket-hmac.mjs && node scripts/check-contract.mjs && node scripts/check-no-autonav.mjs",
37
38
  "demo": "node scripts/make-demo.mjs",
38
39
  "icons": "node scripts/make-icons.mjs"
39
40
  },