dsh-zen-remote 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/AGENTS.md +51 -0
  2. package/LICENSE +38 -0
  3. package/README.md +237 -0
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +17 -0
  10. package/cordis.patch.yml.example +16 -0
  11. package/docs/interface.md +200 -0
  12. package/docs/remote-access.en.md +335 -0
  13. package/docs/remote-access.md +422 -0
  14. package/dsh-push.mjs +213 -0
  15. package/lan-gate.mjs +78 -0
  16. package/lib/client.js +6615 -0
  17. package/lib/index.js +310 -0
  18. package/lib/index.js.map +1 -0
  19. package/lib/lan-gate-server.cjs +626 -0
  20. package/lib/types/client/MobileAttachButton.d.ts +25 -0
  21. package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
  22. package/lib/types/client/MobileAttachChips.d.ts +32 -0
  23. package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
  24. package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
  25. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
  26. package/lib/types/client/MobileHome.d.ts +24 -0
  27. package/lib/types/client/MobileHome.d.ts.map +1 -0
  28. package/lib/types/client/MobileHomeChips.d.ts +76 -0
  29. package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
  30. package/lib/types/client/MobileNavOverlay.d.ts +15 -0
  31. package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
  32. package/lib/types/client/MobileNavToggle.d.ts +18 -0
  33. package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
  34. package/lib/types/client/MobileSessionHeader.d.ts +58 -0
  35. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
  36. package/lib/types/client/MobileSessionInfo.d.ts +41 -0
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
  38. package/lib/types/client/attach-upload.d.ts +78 -0
  39. package/lib/types/client/attach-upload.d.ts.map +1 -0
  40. package/lib/types/client/chips-store.d.ts +13 -0
  41. package/lib/types/client/chips-store.d.ts.map +1 -0
  42. package/lib/types/client/debug.d.ts +9 -0
  43. package/lib/types/client/debug.d.ts.map +1 -0
  44. package/lib/types/client/effects/aionui-compat.d.ts +4 -0
  45. package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
  46. package/lib/types/client/effects/gestures.d.ts +7 -0
  47. package/lib/types/client/effects/gestures.d.ts.map +1 -0
  48. package/lib/types/client/effects/header-status.d.ts +15 -0
  49. package/lib/types/client/effects/header-status.d.ts.map +1 -0
  50. package/lib/types/client/effects/phone-chrome.d.ts +106 -0
  51. package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
  52. package/lib/types/client/effects/turn-fold.d.ts +25 -0
  53. package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +18 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/locales.d.ts +58 -0
  57. package/lib/types/client/locales.d.ts.map +1 -0
  58. package/lib/types/client/nav-store.d.ts +73 -0
  59. package/lib/types/client/nav-store.d.ts.map +1 -0
  60. package/lib/types/client/session-dot.d.ts +12 -0
  61. package/lib/types/client/session-dot.d.ts.map +1 -0
  62. package/lib/types/client/styles/base.css.d.ts +2 -0
  63. package/lib/types/client/styles/base.css.d.ts.map +1 -0
  64. package/lib/types/client/styles/chips.css.d.ts +2 -0
  65. package/lib/types/client/styles/chips.css.d.ts.map +1 -0
  66. package/lib/types/client/styles/compat.css.d.ts +2 -0
  67. package/lib/types/client/styles/compat.css.d.ts.map +1 -0
  68. package/lib/types/client/styles/composer.css.d.ts +2 -0
  69. package/lib/types/client/styles/composer.css.d.ts.map +1 -0
  70. package/lib/types/client/styles/header.css.d.ts +2 -0
  71. package/lib/types/client/styles/header.css.d.ts.map +1 -0
  72. package/lib/types/client/styles/home.css.d.ts +2 -0
  73. package/lib/types/client/styles/home.css.d.ts.map +1 -0
  74. package/lib/types/client/styles/index.d.ts +15 -0
  75. package/lib/types/client/styles/index.d.ts.map +1 -0
  76. package/lib/types/client/styles/info.css.d.ts +2 -0
  77. package/lib/types/client/styles/info.css.d.ts.map +1 -0
  78. package/lib/types/client/styles/layout.css.d.ts +2 -0
  79. package/lib/types/client/styles/layout.css.d.ts.map +1 -0
  80. package/lib/types/client/styles/misc.css.d.ts +2 -0
  81. package/lib/types/client/styles/misc.css.d.ts.map +1 -0
  82. package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
  83. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
  84. package/lib/types/index.d.ts +83 -0
  85. package/lib/types/index.d.ts.map +1 -0
  86. package/llms.txt +20 -0
  87. package/package.json +128 -0
  88. package/pwa/app.css +95 -0
  89. package/pwa/icons/icon-192.png +0 -0
  90. package/pwa/icons/icon-512.png +0 -0
  91. package/pwa/icons/icon-maskable-512.png +0 -0
  92. package/pwa/icons/icon-maskable.svg +1 -0
  93. package/pwa/icons/icon.svg +1 -0
  94. package/pwa/inject.js +190 -0
  95. package/pwa/manifest.json +29 -0
  96. package/pwa/offline.html +38 -0
  97. package/pwa/sw.js +176 -0
  98. package/pwa/touch-gestures.js +94 -0
  99. package/scripts/build-client.mjs +101 -0
  100. package/scripts/cdp-probe.mjs +64 -0
  101. package/scripts/check-attach-upload.mjs +99 -0
  102. package/scripts/check-sunk-viewport.mjs +33 -0
  103. package/scripts/check-upload-endpoint.mjs +168 -0
  104. package/src/client/MobileAttachButton.tsx +125 -0
  105. package/src/client/MobileAttachChips.tsx +85 -0
  106. package/src/client/MobileDrawerFooter.tsx +56 -0
  107. package/src/client/MobileHome.tsx +348 -0
  108. package/src/client/MobileHomeChips.tsx +426 -0
  109. package/src/client/MobileNavOverlay.tsx +279 -0
  110. package/src/client/MobileNavToggle.tsx +52 -0
  111. package/src/client/MobileSessionHeader.tsx +242 -0
  112. package/src/client/MobileSessionInfo.tsx +303 -0
  113. package/src/client/attach-upload.ts +140 -0
  114. package/src/client/chips-store.ts +41 -0
  115. package/src/client/debug.ts +253 -0
  116. package/src/client/effects/aionui-compat.ts +183 -0
  117. package/src/client/effects/gestures.ts +265 -0
  118. package/src/client/effects/header-status.ts +30 -0
  119. package/src/client/effects/phone-chrome.ts +265 -0
  120. package/src/client/effects/turn-fold.ts +258 -0
  121. package/src/client/index.tsx +227 -0
  122. package/src/client/locales.ts +109 -0
  123. package/src/client/nav-store.ts +78 -0
  124. package/src/client/session-dot.ts +17 -0
  125. package/src/client/styles/base.css.ts +167 -0
  126. package/src/client/styles/chips.css.ts +224 -0
  127. package/src/client/styles/compat.css.ts +681 -0
  128. package/src/client/styles/composer.css.ts +504 -0
  129. package/src/client/styles/header.css.ts +368 -0
  130. package/src/client/styles/home.css.ts +477 -0
  131. package/src/client/styles/index.ts +25 -0
  132. package/src/client/styles/info.css.ts +238 -0
  133. package/src/client/styles/layout.css.ts +501 -0
  134. package/src/client/styles/misc.css.ts +128 -0
  135. package/src/client/styles/turn-fold.css.ts +106 -0
  136. package/src/index.ts +343 -0
package/pwa/inject.js ADDED
@@ -0,0 +1,190 @@
1
+ /* dsh-mobile-pwa · PWA bootstrap (injected into DSH page)
2
+ * Runs on every device (desktop included): registers the service worker
3
+ * (offline cache + notifications) and wires the agent-done push opt-in
4
+ * unconditionally. Touch gestures are the only piece gated by device kind —
5
+ * loaded on everything that isn't explicitly marked "desktop", see below.
6
+ */
7
+ (function () {
8
+ 'use strict'
9
+ if (!window.__DSH_PWA__) window.__DSH_PWA__ = {}
10
+
11
+ // ---- Register service worker ----------------------------------------
12
+ // Explicit scope: '/' — the script lives at /pwa/sw.js, so its default
13
+ // scope is only /pwa/ and it would never control the app itself (start_url
14
+ // "/"). The gateway sends Service-Worker-Allowed: / with the sw.js
15
+ // response so Chrome permits a scope wider than the script's own directory.
16
+ if ('serviceWorker' in navigator) {
17
+ window.addEventListener('load', () => {
18
+ navigator.serviceWorker.register('/pwa/sw.js', { scope: '/' }).then((reg) => {
19
+ window.__DSH_PWA__.reg = reg
20
+ }).catch((err) => {
21
+ console.warn('[dsh-pwa] SW registration failed:', err)
22
+ })
23
+ })
24
+ }
25
+
26
+ // ---- Load touch gestures on every non-desktop device -----------------
27
+ // A paired device defaults to kind "auto" — the gateway never sets
28
+ // data-lan-device for it (see lib/lan-gate-server.cjs), only for an
29
+ // explicit "phone" or "desktop". Gating on the literal "phone" value meant
30
+ // this never loaded on a real phone left at its default kind. Matching
31
+ // app.css's own `:not([data-lan-device="desktop"])` gate here instead:
32
+ // anything that isn't explicitly desktop gets touch gestures.
33
+ var isNonDesktop = document.documentElement.getAttribute('data-lan-device') !== 'desktop'
34
+ if (isNonDesktop) {
35
+ var g = document.createElement('script')
36
+ g.src = '/pwa/touch-gestures.js'
37
+ g.async = true
38
+ document.head.appendChild(g)
39
+ }
40
+
41
+ // ---- Agent-done notification (Web Push) -----------------------------
42
+ // Subscribes to a gateway push endpoint. The gateway polls DSH event state and
43
+ // triggers push. Grant & subscribe are opt-in via the button appended below.
44
+ window.__DSH_PWA__.subscribe = function subscribe() {
45
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
46
+ alert('当前浏览器不支持推送通知')
47
+ return Promise.reject(new Error('push unsupported'))
48
+ }
49
+ return new Promise(function (resolve, reject) {
50
+ if (!window.__DSH_PWA__.reg) {
51
+ navigator.serviceWorker.ready.then(function (reg) {
52
+ window.__DSH_PWA__.reg = reg
53
+ doSubscribe(reg).then(resolve).catch(reject)
54
+ }).catch(reject)
55
+ } else {
56
+ doSubscribe(window.__DSH_PWA__.reg).then(resolve).catch(reject)
57
+ }
58
+ })
59
+ }
60
+
61
+ // VAPID public key arrives base64url-encoded via the injected bootstrap.
62
+ function vapidKeyBytes() {
63
+ var s = String(window.__DSH_PWA__.vapid || '')
64
+ if (!s) return undefined
65
+ s = s.replace(/-/g, '+').replace(/_/g, '/')
66
+ while (s.length % 4) s += '='
67
+ var raw = atob(s)
68
+ var a = new Uint8Array(raw.length)
69
+ for (var i = 0; i < raw.length; i++) a[i] = raw.charCodeAt(i)
70
+ return a
71
+ }
72
+
73
+ function doSubscribe(reg) {
74
+ return reg.pushManager.subscribe({
75
+ userVisibleOnly: true,
76
+ applicationServerKey: vapidKeyBytes()
77
+ }).then(function (sub) {
78
+ // Notify the gateway so it can route push to this subscription.
79
+ return fetch('/pwa/push/subscribe', {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({ subscription: sub.toJSON() })
83
+ })
84
+ })
85
+ }
86
+
87
+ // ---- Push opt-in ------------------------------------------------------
88
+ // Deliberately NOT gated on device kind: a device left at the default
89
+ // "auto" carries no data-lan-device attribute, and desktop browsers do
90
+ // Web Push too. Gating this on isPhone silently disabled push for every
91
+ // device that was never explicitly marked as a phone.
92
+ var pushSupported = 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
93
+ var SNOOZE_KEY = 'dsh-pwa-notif-snooze'
94
+ var SNOOZE_MS = 7 * 24 * 3600 * 1000
95
+ // iOS grants Web Push only to home-screen installs, never to a Safari tab.
96
+ var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
97
+ var isStandalone = window.navigator.standalone === true ||
98
+ (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches)
99
+
100
+ function postSubscription(sub) {
101
+ return fetch('/pwa/push/subscribe', {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ subscription: sub.toJSON() })
105
+ })
106
+ }
107
+
108
+ // Permission already granted: make sure the gateway actually holds this
109
+ // device's subscription (it may have been lost to a state reset, or the
110
+ // original POST may have failed). No prompt is shown — none is needed.
111
+ function resyncPush() {
112
+ if (!pushSupported || window.Notification.permission !== 'granted') return
113
+ navigator.serviceWorker.ready.then(function (reg) {
114
+ return reg.pushManager.getSubscription().then(function (sub) {
115
+ if (sub) return postSubscription(sub)
116
+ return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidKeyBytes() }).then(postSubscription)
117
+ })
118
+ }).catch(function (err) { console.warn('[dsh-pwa] push resync failed:', err) })
119
+ }
120
+
121
+ function card(title, bodyHtml, buttonsHtml) {
122
+ var el = document.createElement('div')
123
+ el.id = 'dsh-pwa-notif-hint'
124
+ el.style.cssText =
125
+ 'position:fixed;left:max(12px,env(safe-area-inset-left));right:max(12px,env(safe-area-inset-right));' +
126
+ 'bottom:max(96px,calc(env(safe-area-inset-bottom) + 96px));z-index:2147483005;max-width:420px;margin:0 auto;' +
127
+ 'background:rgba(22,26,34,.96);border:1px solid #2a2f3a;border-radius:14px;' +
128
+ 'padding:14px 16px;color:#e6e8ec;font:13px/1.6 system-ui;' +
129
+ '-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);box-shadow:0 10px 30px rgba(0,0,0,.5)'
130
+ el.innerHTML = '<div style="font-weight:600;margin-bottom:4px">' + title + '</div>' +
131
+ '<div style="color:#9aa3b2;margin-bottom:10px">' + bodyHtml + '</div>' +
132
+ '<div style="display:flex;gap:8px">' + buttonsHtml + '</div>'
133
+ document.body.appendChild(el)
134
+ return el
135
+ }
136
+ var BTN_ON = '<button data-act="on" style="flex:1;background:#4c8dff;color:#fff;border:0;border-radius:9px;padding:9px 0;font-weight:600">开启</button>'
137
+ var BTN_OFF = '<button data-act="off" style="flex:1;background:#2a2f3a;color:#9aa3b2;border:0;border-radius:9px;padding:9px 0">暂不</button>'
138
+ var BTN_CLOSE = '<button data-act="off" style="flex:1;background:#2a2f3a;color:#9aa3b2;border:0;border-radius:9px;padding:9px 0">知道了</button>'
139
+
140
+ // Callable by hand at any time: window.__DSH_PWA__.askPush()
141
+ window.__DSH_PWA__.askPush = function askPush(manual) {
142
+ var old = document.getElementById('dsh-pwa-notif-hint')
143
+ if (old) old.remove()
144
+ if (!pushSupported) {
145
+ if (isIOS && !isStandalone) {
146
+ card('🔔 任务完成提醒', '在 iPhone 上,推送通知只对「添加到主屏幕」后的应用生效。请先用 Safari 的分享菜单添加到主屏幕,再从主屏图标打开本页。', BTN_CLOSE)
147
+ .querySelector('[data-act="off"]').addEventListener('click', function () { document.getElementById('dsh-pwa-notif-hint').remove() })
148
+ } else if (manual) {
149
+ alert('当前浏览器不支持 Web Push 通知')
150
+ }
151
+ return
152
+ }
153
+ if (window.Notification.permission === 'denied') {
154
+ if (manual) alert('通知权限已被拒绝,请到浏览器/系统的站点设置里重新允许')
155
+ return
156
+ }
157
+ if (window.Notification.permission === 'granted') { resyncPush(); return }
158
+
159
+ var el = card('🔔 任务完成提醒',
160
+ '开启后,智能体干完活会推送通知到这台设备,即使你切到别的 App。通知不含对话内容。',
161
+ BTN_ON + BTN_OFF)
162
+ el.querySelector('[data-act="on"]').addEventListener('click', function () {
163
+ el.remove()
164
+ // Must run inside the click gesture: Safari rejects subscribe() otherwise.
165
+ window.__DSH_PWA__.subscribe().catch(function (err) {
166
+ console.warn('[dsh-pwa] subscribe failed:', err)
167
+ alert('开启失败:' + (err && err.message ? err.message : err))
168
+ })
169
+ })
170
+ el.querySelector('[data-act="off"]').addEventListener('click', function () {
171
+ try { localStorage.setItem(SNOOZE_KEY, String(Date.now())) } catch (e) {}
172
+ el.remove()
173
+ })
174
+ }
175
+
176
+ if (pushSupported || (isIOS && !isStandalone)) {
177
+ var snoozedAt = 0
178
+ try { snoozedAt = Number(localStorage.getItem(SNOOZE_KEY) || 0) } catch (e) {}
179
+ // Migrate the old permanent dismissal to a snooze so previously dismissed
180
+ // devices get another chance instead of being silenced forever.
181
+ try {
182
+ if (localStorage.getItem('dsh-pwa-notif-hint')) { localStorage.removeItem('dsh-pwa-notif-hint'); snoozedAt = 0 }
183
+ } catch (e) {}
184
+ if (pushSupported && window.Notification.permission === 'granted') resyncPush()
185
+ else if (Date.now() - snoozedAt > SNOOZE_MS) {
186
+ if (document.readyState === 'complete') window.__DSH_PWA__.askPush()
187
+ else window.addEventListener('load', function () { window.__DSH_PWA__.askPush() })
188
+ }
189
+ }
190
+ })()
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "DeepSeek Harness Mobile",
3
+ "short_name": "DSH Mobile",
4
+ "description": "DeepSeek Harness on your phone. Secure remote access to your own DSH server.",
5
+ "id": "/",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "orientation": "any",
10
+ "background_color": "#f9fafb",
11
+ "theme_color": "#0f1115",
12
+ "categories": ["developer tools", "productivity", "ai"],
13
+ "lang": "zh-CN",
14
+ "dir": "auto",
15
+ "prefer_related_applications": false,
16
+ "shortcuts": [
17
+ {
18
+ "name": "New conversation",
19
+ "short_name": "New chat",
20
+ "url": "/",
21
+ "icons": [{ "src": "/pwa/icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }]
22
+ }
23
+ ],
24
+ "icons": [
25
+ { "src": "/pwa/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
26
+ { "src": "/pwa/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
27
+ { "src": "/pwa/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
28
+ ]
29
+ }
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0f1115">
7
+ <title>离线 · DSH</title>
8
+ <style>
9
+ * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
10
+ html, body { margin: 0; padding: 0; }
11
+ body {
12
+ min-height: 100dvh; display: flex; align-items: center; justify-content: center;
13
+ font-family: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
14
+ background: radial-gradient(1100px 700px at 50% -10%, #1b2233 0%, #0f1115 55%);
15
+ color: #e6e8ec; padding: max(20px, env(safe-area-inset-top)) 16px;
16
+ -webkit-text-size-adjust: 100%;
17
+ }
18
+ .card { width: 100%; max-width: 420px; text-align: center; }
19
+ .dot { width: 80px; height: 80px; margin: 0 auto 20px; border-radius: 24px;
20
+ background: linear-gradient(135deg, #4c8dff, #7a5cff); display: flex; align-items: center;
21
+ justify-content: center; font-size: 34px; font-weight: 700; color: #fff; }
22
+ h1 { font-size: 20px; margin: 0 0 8px; }
23
+ p { font-size: 14px; line-height: 1.8; color: #9aa3b2; margin: 0; }
24
+ .spin { width: 38px; height: 38px; margin: 26px auto 0; border-radius: 50%;
25
+ border: 3px solid #2a2f3a; border-top-color: #4c8dff; animation: a .9s linear infinite; }
26
+ @keyframes a { to { transform: rotate(360deg); } }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div class="card">
31
+ <div class="dot">DSH</div>
32
+ <h1>离线 / Offline</h1>
33
+ <p>无法连接到 DSH 服务器。<br>请在服务器侧确认网关与 DSH 仍在运行。</p>
34
+ <div class="spin"></div>
35
+ </div>
36
+ <script>setInterval(() => location.reload(), 5000)</script>
37
+ </body>
38
+ </html>
package/pwa/sw.js ADDED
@@ -0,0 +1,176 @@
1
+ /* dsh-mobile-pwa · service worker
2
+ * Offline-capable caching + agent-done push notifications for the DSH Web UI.
3
+ *
4
+ * Strategy (v3 — see the "new DOM + old CSS" postmortem below):
5
+ * - True static shell (manifest, icons, offline fallback) -> cache-first.
6
+ * These are the only assets whose bytes cannot change without an entry in
7
+ * SHELL_PATHS changing too, so caching them aggressively is safe.
8
+ * - Everything else same-origin (DSH's own JS/CSS bundle, plugin client
9
+ * bundles incl. this plugin's own app.css/inject.js/touch-gestures.js,
10
+ * API calls, fonts, images) -> network-first, falling back to cache only
11
+ * when the network fetch actually fails (offline).
12
+ * - SPA navigations (HTML doc) -> network-first, falling back to the last
13
+ * successfully loaded page when offline.
14
+ *
15
+ * v2 treated ALL JS/CSS/fonts/images as cache-first-ish (stale-while-
16
+ * revalidate: serve the cached copy immediately, refresh in the background).
17
+ * That's exactly backwards for a bundle that changes on every deploy without
18
+ * its filename changing: a phone that had the PWA open across a deploy kept
19
+ * serving yesterday's cached JS/CSS on the very next cold start, while HTML
20
+ * navigation (already network-first) served today's markup — a "new DOM +
21
+ * old CSS" mismatch (e.g. cards rendered full-bleed) that a fresh local
22
+ * profile never reproduces because it has nothing stale to serve. Only the
23
+ * handful of files in SHELL_PATHS are safe to keep cache-first.
24
+ *
25
+ * Served by the gateway at /pwa/sw.js. Registered from the injected PWA script.
26
+ */
27
+ 'use strict'
28
+
29
+ const SHELL_CACHE = 'dsh-mobile-pwa-shell-v3'
30
+ const RUNTIME_CACHE = 'dsh-mobile-pwa-runtime-v3'
31
+
32
+ const SHELL_PATHS = new Set([
33
+ '/pwa/manifest.json',
34
+ '/pwa/icons/icon-192.png',
35
+ '/pwa/icons/icon-512.png',
36
+ '/pwa/icons/icon-maskable-512.png',
37
+ '/pwa/offline.html'
38
+ ])
39
+
40
+ self.addEventListener('install', (event) => {
41
+ event.waitUntil(
42
+ caches.open(SHELL_CACHE).then((cache) => cache.addAll([
43
+ '/pwa/manifest.json',
44
+ '/pwa/icons/icon-192.png',
45
+ '/pwa/icons/icon-512.png',
46
+ '/pwa/icons/icon-maskable-512.png'
47
+ ]))
48
+ )
49
+ // Safe to activate immediately: this worker only decides how *future*
50
+ // fetches are served (cache-first for the shell, network-first for
51
+ // everything else). A tab that's already loaded keeps running the JS it
52
+ // already has in memory regardless of which SW version is "active" — it
53
+ // only starts talking to the new worker on its next fetch/navigation — so
54
+ // skipping the wait doesn't yank code out from under a running page. What
55
+ // it *does* fix: without it, an old SW can sit "waiting" indefinitely on a
56
+ // phone that never fully closes the PWA, which is the same staleness bug
57
+ // this rewrite targets, just at the SW-registration layer instead of the
58
+ // cache layer.
59
+ self.skipWaiting()
60
+ })
61
+
62
+ self.addEventListener('activate', (event) => {
63
+ event.waitUntil(
64
+ caches.keys().then((keys) =>
65
+ Promise.all(
66
+ keys
67
+ .filter((k) => k !== SHELL_CACHE && k !== RUNTIME_CACHE)
68
+ .map((k) => caches.delete(k))
69
+ )
70
+ )
71
+ )
72
+ // Take over already-open tabs right away rather than waiting for their
73
+ // next full reload. Paired with network-first below, this means an open
74
+ // tab starts getting fresh JS/CSS/HTML on its very next fetch instead of
75
+ // continuing to hit whatever the previous worker had cached.
76
+ self.clients.claim()
77
+ })
78
+
79
+ // ---- fetch handler -------------------------------------------------------
80
+ self.addEventListener('fetch', (event) => {
81
+ const req = event.request
82
+ const url = new URL(req.url)
83
+
84
+ // Same-origin only; we only manage the DSH origin behind the gateway.
85
+ if (url.origin !== self.location.origin) return
86
+
87
+ // 1. True static shell -> cache-first.
88
+ if (req.method === 'GET' && SHELL_PATHS.has(url.pathname)) {
89
+ event.respondWith(cacheFirst(req, SHELL_CACHE))
90
+ return
91
+ }
92
+
93
+ // 2. HTML navigations -> network-first with offline fallback to the last
94
+ // successfully loaded page.
95
+ if (req.mode === 'navigate') {
96
+ event.respondWith(networkFirstNavigation(req))
97
+ return
98
+ }
99
+
100
+ // 3. Everything else same-origin GET -> network-first. This is the DSH
101
+ // client bundle (JS/CSS, incl. plugin client bundles), API calls,
102
+ // fonts and images: all of it can change on any deploy, so the server's
103
+ // current copy always wins; the cache only serves it when offline.
104
+ if (req.method === 'GET') {
105
+ event.respondWith(networkFirst(req, RUNTIME_CACHE))
106
+ return
107
+ }
108
+ })
109
+
110
+ async function cacheFirst(req, cacheName) {
111
+ const cache = await caches.open(cacheName)
112
+ const cached = await cache.match(req)
113
+ if (cached) return cached
114
+ const res = await fetch(req)
115
+ if (res && res.ok) cache.put(req, res.clone())
116
+ return res
117
+ }
118
+
119
+ async function networkFirst(req, cacheName) {
120
+ const cache = await caches.open(cacheName)
121
+ try {
122
+ const res = await fetch(req)
123
+ if (res && res.ok && req.method === 'GET') cache.put(req, res.clone())
124
+ return res
125
+ } catch (err) {
126
+ const cached = await cache.match(req)
127
+ if (cached) return cached
128
+ throw err
129
+ }
130
+ }
131
+
132
+ async function networkFirstNavigation(req) {
133
+ const cache = await caches.open(SHELL_CACHE)
134
+ try {
135
+ const res = await fetch(req)
136
+ if (res && res.ok) cache.put('/pwa/offline.html', res.clone())
137
+ return res
138
+ } catch (err) {
139
+ const cached = await cache.match('/pwa/offline.html')
140
+ if (cached) return cached
141
+ // Last resort: offline fallback hint.
142
+ return new Response(
143
+ '<!doctype html><meta charset="utf-8"><title>离线</title><style>body{font-family:system-ui;background:#0f1115;color:#e6e8ec;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}p{max-width:24em;text-align:center;line-height:1.7}</style><script>setInterval(()=>location.reload(),4000)</script><p>无法连接到 DSH 服务器,正在重试…<br>请确认你的网关与服务仍在运行。</p>',
144
+ { headers: { 'Content-Type': 'text/html; charset=utf-8' } }
145
+ )
146
+ }
147
+ }
148
+
149
+ // ---- push (agent-done) notifications ------------------------------------
150
+ self.addEventListener('push', (event) => {
151
+ let data = {}
152
+ try { data = event.data ? event.data.json() : {} } catch (e) { /* ignore */ }
153
+ const title = data.title || 'DSH 任务完成'
154
+ const options = {
155
+ body: data.body || '你的智能体已完成某一步。',
156
+ icon: '/pwa/icons/icon-192.png',
157
+ badge: '/pwa/icons/icon-192.png',
158
+ tag: data.tag || 'dsh-agent-done',
159
+ renotify: true,
160
+ data: data.data || {}
161
+ }
162
+ event.waitUntil(self.registration.showNotification(title, options))
163
+ })
164
+
165
+ self.addEventListener('notificationclick', (event) => {
166
+ event.notification.close()
167
+ const target = (event.notification.data && event.notification.data.url) || '/'
168
+ event.waitUntil(
169
+ self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
170
+ for (const client of list) {
171
+ if ('focus' in client) { client.navigate(target); return client.focus() }
172
+ }
173
+ return self.clients.openWindow(target)
174
+ })
175
+ )
176
+ })
@@ -0,0 +1,94 @@
1
+ /* dsh-mobile-pwa · touch gestures
2
+ * Touch-first gestures for the DSH Web UI on phones:
3
+ * - Pinch to resize code / markdown font size
4
+ *
5
+ * Edge-swipe back (history.back) used to live here too; removed (design
6
+ * doc "手势" row, 2026-08-17 fourth revision): history.back() is a no-op
7
+ * against this SPA's own client-side routing, and the 24px left-edge hot
8
+ * zone div (z-index 2147483001, touch-action:none) silently ate every touch
9
+ * that started there — including dsh-mobile-nav's own new left-edge
10
+ * swipe-back gesture (effects/gestures.ts in the dsh-web-mobile plugin),
11
+ * which now owns that same 24px strip and actually does something useful
12
+ * with it (close the open sheet/panel, or return to the session list).
13
+ *
14
+ * Pull-to-refresh used to live here too; it was removed (real-device
15
+ * feedback: any accidental overscroll fired a full page reload, which S1's
16
+ * page-stack rules always land on the conversation *list*, not back on the
17
+ * chat the user was in — trigger-happy and disorienting mid-conversation).
18
+ * app.css still disables the browser's own native overscroll/rubber-band at
19
+ * the document edge on its own merits, independent of this file.
20
+ *
21
+ * Loaded on every device that isn't explicitly marked "desktop" via the
22
+ * injected PWA script. Keeps out of the way on desktop.
23
+ */
24
+ (function () {
25
+ 'use strict'
26
+ if (window.matchMedia('(pointer: coarse)').matches === false) return
27
+
28
+ // ---- Pinch to resize font -------------------------------------------
29
+ const FONT_KEY = 'dsh-pwa-fontscale'
30
+ const SPACES = [10, 11, 12, 13, 14, 15, 16, 17, 18]
31
+ const root = document.documentElement
32
+ let baseScale = parseFloat(localStorage.getItem(FONT_KEY)) || 1
33
+ applyFont(baseScale)
34
+
35
+ let pinchStartDist = null
36
+ let pinchStartScale = baseScale
37
+ document.addEventListener('touchstart', (e) => {
38
+ if (e.touches.length === 2) {
39
+ pinchStartDist = Math.hypot(
40
+ e.touches[0].clientX - e.touches[1].clientX,
41
+ e.touches[0].clientY - e.touches[1].clientY
42
+ )
43
+ pinchStartScale = baseScale
44
+ }
45
+ }, { passive: true })
46
+
47
+ document.addEventListener('touchmove', (e) => {
48
+ if (e.touches.length !== 2 || pinchStartDist == null) return
49
+ e.preventDefault()
50
+ const d = Math.hypot(
51
+ e.touches[0].clientX - e.touches[1].clientX,
52
+ e.touches[0].clientY - e.touches[1].clientY
53
+ )
54
+ const ratio = d / pinchStartDist
55
+ const candidates = SPACES.map((s) => s * 0.1 * ratio)
56
+ const next = Math.min(1.8, Math.max(0.9, pinchStartScale * ratio))
57
+ // Snap to nearest discrete step to avoid jitter.
58
+ const snapped = snapTo(next)
59
+ if (snapped !== baseScale) {
60
+ baseScale = snapped
61
+ applyFont(baseScale)
62
+ localStorage.setItem(FONT_KEY, String(baseScale))
63
+ }
64
+ }, { passive: false })
65
+
66
+ function snapTo(v) {
67
+ const sorted = [...SPACES].sort((a, b) => Math.abs(a - v * 10) - Math.abs(b - v * 10))
68
+ return sorted[0] / 10
69
+ }
70
+
71
+ document.addEventListener('touchend', () => { pinchStartDist = null }, { passive: true })
72
+
73
+ function applyFont(scale) {
74
+ root.style.setProperty('--dsh-pwa-font-scale', String(scale))
75
+ }
76
+
77
+ // Add a small floating reset button when scale != 1.
78
+ if (baseScale !== 1) {
79
+ const reset = document.createElement('button')
80
+ reset.textContent = '字AA'
81
+ reset.style.cssText =
82
+ 'position:fixed;right:max(10px,env(safe-area-inset-right));' +
83
+ 'bottom:max(110px,calc(env(safe-area-inset-bottom) + 110px));z-index:2147483002;' +
84
+ 'background:rgba(76,141,255,.92);color:#fff;border:0;border-radius:999px;' +
85
+ 'padding:8px 12px;font:600 12px/1 system-ui;box-shadow:0 6px 20px rgba(0,0,0,.4)'
86
+ reset.addEventListener('click', () => {
87
+ baseScale = 1
88
+ applyFont(1)
89
+ localStorage.setItem(FONT_KEY, '1')
90
+ reset.remove()
91
+ })
92
+ document.body.appendChild(reset)
93
+ }
94
+ })()
@@ -0,0 +1,101 @@
1
+ // Wrap the tsc-compiled CommonJS client program into the DSH browser loader
2
+ // shape: window.__ModuleLoader__.load({ id, factory: (require) => ... }).
3
+ // Relative modules are inlined with a tiny local require; platform modules
4
+ // (react, primitives, ...) stay as require() calls and are resolved by the
5
+ // host's browser module table.
6
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
7
+ import { dirname, join, posix } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ const root = dirname(fileURLToPath(new URL('../package.json', import.meta.url)))
11
+ const buildDir = join(root, '.client-build')
12
+ const outputPath = join(root, 'lib', 'client.js')
13
+
14
+ // Walk the emit dir recursively and key each module by its forward-slash
15
+ // relative path (e.g. "index.js", "styles/index.js"). Nested emit dirs now
16
+ // appear because the CSS lives in src/client/styles/.
17
+ async function collectSources(dir, { rel = '' } = {}) {
18
+ const sources = new Map()
19
+ for (const entry of (await readdir(dir, { withFileTypes: true }))) {
20
+ const abs = join(dir, entry.name)
21
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name
22
+ if (entry.isDirectory()) {
23
+ for (const [k, v] of await collectSources(abs, { rel: relPath })) {
24
+ sources.set(k, v)
25
+ }
26
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
27
+ sources.set(
28
+ relPath,
29
+ (await readFile(abs, 'utf8')).replace(/\n?\/\/# sourceMappingURL=.*$/u, ''),
30
+ )
31
+ }
32
+ }
33
+ return sources
34
+ }
35
+
36
+ const sources = await collectSources(buildDir)
37
+
38
+ const REQUIRE_RE = /require\("(\.[^"]+\.js)"\)/g
39
+ // Resolve a `./x.js` or `../x.js` require relative to its parent module to
40
+ // the canonical forward-slash key used in __modules (e.g. styles/index.js
41
+ // requires "./base.css.js" -> "styles/base.css.js"; effects/header-status.js
42
+ // requires "../session-dot.js" -> "session-dot.js"). posix.join already
43
+ // interprets the leading "./"/"../" itself, so passing the FULL relative
44
+ // string (not the old rel.slice(2), which only stripped "./" and silently
45
+ // mis-resolved "../" into a bogus same-directory path) is both correct and
46
+ // simpler.
47
+ const resolveChild = (parent, rel) => {
48
+ const joined = posix.join(posix.dirname(parent), rel)
49
+ return joined === '.' ? '' : joined
50
+ }
51
+
52
+ // Dependency-first topological order from the entry.
53
+ const visited = new Set()
54
+ const order = []
55
+ const visit = (file) => {
56
+ if (visited.has(file)) return
57
+ visited.add(file)
58
+ const src = sources.get(file)
59
+ if (!src) throw new Error(`client module not found for require: ${file}`)
60
+ for (const match of src.matchAll(REQUIRE_RE)) {
61
+ visit(resolveChild(file, match[1]))
62
+ }
63
+ order.push(file)
64
+ }
65
+ visit('index.js')
66
+
67
+ const modules = order
68
+ .map((file) => {
69
+ // Rewrite each relative require to its canonical path so the runtime
70
+ // __localRequire (id.slice(2), entry-relative) resolves nested modules.
71
+ const src = sources.get(file).replace(REQUIRE_RE, (m, rel) => `require("./${resolveChild(file, rel)}")`)
72
+ return `__modules[${JSON.stringify(file)}] = function (require, module, exports) {\n${src}\n};`
73
+ })
74
+ .join('\n')
75
+
76
+ const wrapped = [
77
+ 'window.__ModuleLoader__.load({ id: "dsh-zen-remote", factory: (require) => {',
78
+ 'var __modules = {};',
79
+ modules,
80
+ 'var __cache = {};',
81
+ 'function __localRequire(id) {',
82
+ ' if (id.charCodeAt(0) !== 46) return require(id);',
83
+ ' id = id.slice(2);',
84
+ ' var cached = __cache[id];',
85
+ ' if (cached) return cached.exports;',
86
+ ' var module = { exports: {} };',
87
+ ' __cache[id] = module;',
88
+ ' __modules[id](__localRequire, module, module.exports);',
89
+ ' return module.exports;',
90
+ '}',
91
+ 'var module = { exports: {} };',
92
+ '__modules["index.js"](__localRequire, module, module.exports);',
93
+ 'return module.exports; } });',
94
+ '',
95
+ ].join('\n')
96
+
97
+ await mkdir(dirname(outputPath), { recursive: true })
98
+ await writeFile(outputPath, wrapped)
99
+ await rm(join(root, 'lib', 'client.js.map'), { force: true })
100
+ await rm(buildDir, { recursive: true, force: true })
101
+ console.log(`client bundle written: ${outputPath} (${order.length} modules inlined)`)