@skillpie/dsh-remote 1.0.0 → 1.2.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.
package/README.md CHANGED
@@ -60,12 +60,30 @@ DeepSeek Harness(DSH)插件:像 ZCodeOnline 远程协助一样,用 **8 位远程
60
60
  | `relay.mode` | `gui` | 中转面给访客的体验(同上) |
61
61
  | `relay.maxVisitorsPerHost` | `8` | 单宿主机并发访客/拼接上限 |
62
62
 
63
+ ## 地址栏隐私(远程码不落 URL)
64
+
65
+ 打开 `https://dsh.skillpie.cn/12345678/` 后,页面脚本会把远程码存入浏览器(localStorage + sessionStorage)、立刻用 `history.replaceState` 把地址栏改写为 `https://dsh.skillpie.cn/`,并把 `<base>` 钉到隧道前缀保证资源继续加载。同时注册一个 origin 级 Service Worker(`/dsh-remote-sw.js`):之后直接访问裸域名也会自动恢复到对应宿主机的隧道,**全程不需要把码拼回 URL**。
66
+
67
+ - 转发/收藏裸域名即可再次进入(同一浏览器);`?gui` 参数可跳过恢复(例如访客想看中转站自己的 GUI 或排除故障)。
68
+ - 携带中转站属主会话 cookie(`dsh-auth-*`)的浏览器不受 SW 影响,裸域名始终是中转站自己的 GUI。
69
+ - 宿主机离线时,SW 回源得到的是离线占位页,不会渲染任何宿主内容。
70
+ - 清除站点数据(或换浏览器)后裸域名不再自动恢复,需要原始带码链接重新进入。
71
+ - 码以明文存在访客浏览器 localStorage 中——这是为「无需重复输入」做的权衡;公用电脑请勿勾选记住,或用后清除站点数据。
72
+
73
+ ## 访客文件下载
74
+
75
+ 远程访问时,文件预览页的打开方式菜单(LibreOffice/WorkBuddy/… 那个)会在**最前面**多出一项「下载文件」:点击后把宿主机磁盘上的该文件经隧道流回访客浏览器,由浏览器弹出保存框存到本地——GUI 本身的菜单只能把路径交给宿主机桌面,远程访客原本无从取回生成的文件(xlsx/pdf/任意类型均可)。
76
+
77
+ - 路径来自预览页头的完整路径显示;页面找不到路径时,退用最近一次通过 mux 请求过的文件路径;都拿不到会提示「无法定位文件」。
78
+ - 下载路由 `/dsh-remote-file` 挂在宿主机自己的 web 服务上,仅接受 GET/HEAD,经隧道访问时由宿主会话 cookie 保护;另外校验一个由宿主 hostId 派生的密钥(`x-dsh-remote-key`,自动注入),防止本机回环的其他进程任意读文件。只允许下载普通文件,单文件上限 512MB。
79
+ - 仅访客态注入该菜单项,宿主机本地 GUI 不受影响。
80
+
63
81
  ## 安全(重要)
64
82
 
65
- - **gui 模式下访客拥有与属主等价的完整 GUI 权限**(可见所有会话、可操作界面、含设置页)。远程码就是分享授权:**只把链接给你信任的人**;不再需要时用属主面板「重新生成远程码」立即作废旧链接。
83
+ - **gui 模式下访客拥有与属主等价的完整 GUI 权限**(可见所有会话、可操作界面、含设置页)。远程码就是分享授权:**只把链接给你信任的人**;不再需要时用属主面板「重新生成远程码」立即作废旧链接(浏览器缓存的旧码会失效,SW 恢复到离线占位页)。
66
84
  - 远程码/宿主 token:token 只存哈希;owner 会话 cookie 只在宿主机进程内持有并逐请求注入,不进入访客浏览器。
67
85
  - 每台宿主机限制并发访客;请求体、帧大小、空闲时间均有上限;帧处理器带异常边界,任何桥接错误只断开对应连接,不会影响宿主进程。
68
- - 局限:gui 模式是多宿主共享入口,属主面板的「在线访客」仅统计拼接连接;首次加载资源走隧道一次(之后浏览器缓存生效)。
86
+ - 局限:gui 模式是多宿主共享入口,属主面板的「在线访客」仅统计拼接连接;首次加载资源走隧道一次(之后浏览器缓存生效)。Service Worker 需要安全上下文(https 或 localhost),LAN 明文 http 下仅地址栏摘除生效,无自动恢复。
69
87
 
70
88
  ## 验证(本仓库)
71
89
 
@@ -0,0 +1,159 @@
1
+ /**
2
+ * dsh-remote — host-side visitor file download route.
3
+ *
4
+ * Visitors of the real GUI have no way to save a generated file locally: the
5
+ * built-in file menu only hands paths to the Host desktop (open / reveal),
6
+ * which is invisible to a remote browser. This route reads a file from this
7
+ * host's disk and streams it back with `Content-Disposition: attachment`,
8
+ * so the visitor's browser opens its own save dialog.
9
+ *
10
+ * The route registers on the HOST's own web server under `/dsh-remote-file`;
11
+ * visitor requests reach it through the tunnel (relay prefix proxy or direct
12
+ * pipe) with the bridge-minted owner cookie attached. Everything else is
13
+ * gated by a shared-secret key derived from the host id (`?k=` or the
14
+ * `x-dsh-remote-key` header) so loopback strangers cannot read arbitrary
15
+ * files. The same derivation runs on the relay to stamp the key into the
16
+ * visitor shim, so no protocol change carries it.
17
+ */
18
+ import { createReadStream } from 'node:fs'
19
+ import { stat } from 'node:fs/promises'
20
+ import { createHash } from 'node:crypto'
21
+
22
+ /** Largest file one download may serve. */
23
+ const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024
24
+
25
+ const MIME_BY_EXT = new Map([
26
+ ['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
27
+ ['.xlsm', 'application/vnd.ms-excel.sheet.macroEnabled.12'],
28
+ ['.xls', 'application/vnd.ms-excel'],
29
+ ['.csv', 'text/csv; charset=utf-8'],
30
+ ['.tsv', 'text/tab-separated-values; charset=utf-8'],
31
+ ['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
32
+ ['.doc', 'application/msword'],
33
+ ['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
34
+ ['.ppt', 'application/vnd.ms-powerpoint'],
35
+ ['.pdf', 'application/pdf'],
36
+ ['.json', 'application/json; charset=utf-8'],
37
+ ['.txt', 'text/plain; charset=utf-8'],
38
+ ['.md', 'text/markdown; charset=utf-8'],
39
+ ['.html', 'text/html; charset=utf-8'],
40
+ ['.htm', 'text/html; charset=utf-8'],
41
+ ['.xml', 'application/xml; charset=utf-8'],
42
+ ['.yml', 'text/yaml; charset=utf-8'],
43
+ ['.yaml', 'text/yaml; charset=utf-8'],
44
+ ['.png', 'image/png'],
45
+ ['.jpg', 'image/jpeg'],
46
+ ['.jpeg', 'image/jpeg'],
47
+ ['.gif', 'image/gif'],
48
+ ['.webp', 'image/webp'],
49
+ ['.svg', 'image/svg+xml'],
50
+ ['.zip', 'application/zip'],
51
+ ['.gz', 'application/gzip'],
52
+ ['.mp4', 'video/mp4'],
53
+ ['.mp3', 'audio/mpeg'],
54
+ ['.wav', 'audio/wav'],
55
+ ])
56
+
57
+ export function mimeOf(name) {
58
+ const at = name.lastIndexOf('.')
59
+ if (at === -1) return 'application/octet-stream'
60
+ return MIME_BY_EXT.get(name.slice(at).toLowerCase()) ?? 'application/octet-stream'
61
+ }
62
+
63
+ /** attachment disposition with an UTF-8 form plus an ASCII fallback. */
64
+ export function dispositionOf(name) {
65
+ const clean = String(name ?? '').replace(/[\r\n]/g, ' ').trim() || 'download'
66
+ const fallback = clean.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
67
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(clean)}`
68
+ }
69
+
70
+ /**
71
+ * Shared secret gating the host's file route. Derived from the host id only,
72
+ * so it survives token regeneration and needs no protocol change: the relay
73
+ * derives the same value from the hello hostId to stamp the visitor shim.
74
+ */
75
+ export function fileRouteKey(hostId) {
76
+ return createHash('sha256').update(`dsh-remote-file:v1:${hostId}`).digest('hex').slice(0, 32)
77
+ }
78
+
79
+ /** Constant-time-ish comparison for short hex strings. */
80
+ function keyMatches(given, expected) {
81
+ const a = Buffer.from(String(given ?? ''))
82
+ const b = Buffer.from(String(expected ?? ''))
83
+ if (a.length !== b.length) return false
84
+ let diff = 0
85
+ for (let i = 0; i < b.length; i++) diff |= a[i] ^ b[i]
86
+ return diff === 0
87
+ }
88
+
89
+ /**
90
+ * Register the download route on the host web server.
91
+ * @param services { webServer: object with register(route) → disposer }
92
+ * @param key string from fileRouteKey(identity.hostId)
93
+ * @param log fn(kind, text)
94
+ * @returns disposer
95
+ */
96
+ export function registerFileRoute({ webServer, key, log = () => {} }) {
97
+ return webServer.register({
98
+ kind: 'prefix',
99
+ path: '/dsh-remote-file',
100
+ handler: (req, res) => {
101
+ try {
102
+ void handle(req, res)
103
+ } catch (error) {
104
+ log('file-route', `下载路由处理异常(已隔离): ${error?.message ?? error}`)
105
+ try { res.writeHead(500).end() } catch { try { res.destroy() } catch { /* gone */ } }
106
+ }
107
+ },
108
+ })
109
+
110
+ async function handle(req, res) {
111
+ const url = new URL(req.url ?? '/', 'http://localhost')
112
+ const send = (status, text) => {
113
+ if (res.headersSent) { try { res.destroy() } catch { res.end() } ; return }
114
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
115
+ res.end(`${text}\n`)
116
+ }
117
+ if (!url.pathname.startsWith('/dsh-remote-file')) return send(404, 'not found')
118
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(405, 'method not allowed')
119
+ // Loopback strangers and forged pages must not read files; only requests
120
+ // carrying the key survive (the visitor shim stamps it, tunnel or direct).
121
+ const given = url.searchParams.get('k') ?? req.headers['x-dsh-remote-key'] ?? ''
122
+ if (!keyMatches(given, key)) return send(403, 'bad download key')
123
+ const filePath = url.searchParams.get('path') ?? ''
124
+ if (!filePath || !filePath.startsWith('/') || filePath.includes('\0')) return send(400, 'path must be absolute')
125
+ let info
126
+ try {
127
+ info = await stat(filePath)
128
+ } catch {
129
+ return send(404, 'file not found')
130
+ }
131
+ if (!info.isFile()) return send(404, 'not a regular file')
132
+ if (info.size > MAX_DOWNLOAD_BYTES) return send(413, 'file too large')
133
+ const name = filePath.split('/').pop() || 'download'
134
+ let stream
135
+ try {
136
+ stream = createReadStream(filePath)
137
+ } catch (error) {
138
+ return send(404, `file unreadable: ${error?.message ?? error}`)
139
+ }
140
+ res.writeHead(200, {
141
+ 'Content-Type': mimeOf(name),
142
+ 'Content-Length': String(info.size),
143
+ 'Content-Disposition': dispositionOf(name),
144
+ 'Cache-Control': 'no-store',
145
+ 'X-Content-Type-Options': 'nosniff',
146
+ })
147
+ stream.on('error', (error) => {
148
+ log('file-route', `下载流中断: ${error?.message ?? error}`)
149
+ try { res.destroy() } catch { res.end() }
150
+ })
151
+ req.on('close', () => { try { stream.destroy() } catch { /* gone */ } })
152
+ if (req.method === 'HEAD') {
153
+ stream.destroy()
154
+ res.end()
155
+ return
156
+ }
157
+ stream.pipe(res)
158
+ }
159
+ }
package/lib/index.js CHANGED
@@ -28,7 +28,8 @@ import { HOST_UPGRADE_PATH, OWNER_API_PATH, PROTOCOL_VERSION, generateCode, gene
28
28
  import { HostEngine } from './engine.js'
29
29
  import { GuiBridge } from './bridge.js'
30
30
  import { RelayLink } from './remote.js'
31
- import { RelayCore } from './relay-core.js'
31
+ import { RelayCore, serveServiceWorker } from './relay-core.js'
32
+ import { registerFileRoute, fileRouteKey } from './file-route.js'
32
33
  import { consoleHtml } from './console.js'
33
34
  import { createPipePair } from './pipe.js'
34
35
 
@@ -285,6 +286,15 @@ export default {
285
286
  logLine('owner-api', `属主 API 已挂载(${OWNER_API_PATH})`)
286
287
  }
287
288
 
289
+ // 5.5 Visitor file downloads: stream files from this host to the
290
+ // remote browser with a save dialog (see lib/file-route.js).
291
+ // Host roles only; the key derives from the host id, which the
292
+ // relay also derives from the hello to stamp the visitor shim.
293
+ if (enabled || direct) {
294
+ disposers.push(registerFileRoute({ webServer, key: fileRouteKey(identity.hostId), log: logLine }))
295
+ logLine('file-route', '访客文件下载路由已挂载(/dsh-remote-file)')
296
+ }
297
+
288
298
  // 6. Direct mode: the same surface on the local web server.
289
299
  if (direct) {
290
300
  const [hostEnd, relayEnd] = createPipePair()
@@ -316,7 +326,20 @@ export default {
316
326
  logLine('direct', `本机直连路由已挂载(/${identity.code}/,模式 ${mode})`)
317
327
  }
318
328
 
319
- // 7. Relay face (server deployments only).
329
+ // 7. Visitor restore worker: conceals the code from the address bar and
330
+ // keeps the bare origin reopening the tunnel (see concealScript). The
331
+ // worker also probes 8-digit code paths and swaps a 404 (unknown or
332
+ // offline-revoked code) for the code-entry dialog.
333
+ if (mode === 'gui' && (direct || relayFaceEnabled)) {
334
+ disposers.push(webServer.register({
335
+ kind: 'exact',
336
+ path: '/dsh-remote-sw.js',
337
+ handler: (req, res) => { try { serveServiceWorker(req, res) } catch { try { res.destroy() } catch { /* gone */ } } },
338
+ }))
339
+ logLine('sw', '访客恢复 Worker 已挂载(/dsh-remote-sw.js)')
340
+ }
341
+
342
+ // 8. Relay face (server deployments only).
320
343
  if (relayFaceEnabled) {
321
344
  const relayCore = new RelayCore({
322
345
  webServer,
package/lib/relay-core.js CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  decodeBin,
33
33
  } from './protocol.js'
34
34
  import { adaptSocket } from './ws-adapter.js'
35
+ import { fileRouteKey } from './file-route.js'
35
36
 
36
37
  const HELLO_TIMEOUT_MS = 5_000
37
38
  const OFFLINE_GRACE_MS = 10 * 60_000
@@ -64,6 +65,92 @@ export class RelayCore {
64
65
  this.cmdSeq = 0
65
66
  this.timers = new Set()
66
67
  this.closed = false
68
+ this.enterDisposers = []
69
+ // 未知远程码兜底:webServer 的前缀匹配是「p 或 p/」语义,任何短于 8 字符
70
+ // 的前缀都命中不了 /<code>/ 路径,所以无法用通配路由接管数字码;改为
71
+ // ①独立输码页路由(供手动访问/边缘转发目标),②由 index.js 用
72
+ // webServer.tapIndex 把守卫脚本注进服务器 SPA 文档——未知码路径命中的
73
+ // 正是 SPA fallback 渲染的 index,守卫脚本据 pathname 弹输码框。
74
+ if (this.mode === 'gui') {
75
+ try {
76
+ this.enterDisposers.push(this.webServer.register({
77
+ kind: 'prefix',
78
+ path: '/dsh-remote-enter',
79
+ handler: (req, res) => {
80
+ try { this.serveEnter(req, res) } catch (error) {
81
+ this.log('relay', `输码页请求失败(已隔离): ${error?.message ?? error}`)
82
+ try { res.writeHead(500).end() } catch { /* gone */ }
83
+ }
84
+ },
85
+ }))
86
+ } catch (error) {
87
+ this.log('relay', `输码路由注册失败(已隔离): ${error?.message ?? error}`)
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Standalone code-entry page: `/dsh-remote-enter?code=NNNNNNNN` renders the
94
+ * dialog pre-flagged with that code as invalid; without a code it opens the
95
+ * blank dialog. Also the redirect target for edge-level unknown-code rules.
96
+ */
97
+ serveEnter(req, res) {
98
+ let code = ''
99
+ try { code = new URL(req.url ?? '/', 'http://localhost').searchParams.get('code') ?? '' } catch { code = '' }
100
+ const send = (status, body, headers = {}) => {
101
+ if (res.headersSent) { try { res.destroy() } catch { res.end() } ; return }
102
+ res.writeHead(status, { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...headers })
103
+ res.end(body)
104
+ }
105
+ const bad = /^[1-9]\d{7}$/.test(code) ? code : ''
106
+ if (code !== '' && bad === '') {
107
+ send(400, 'bad code\n', { 'Content-Type': 'text/plain; charset=utf-8' })
108
+ return
109
+ }
110
+ send(200, this.enterHtml(bad), { 'Content-Type': 'text/html; charset=utf-8' })
111
+ }
112
+
113
+ /**
114
+ * Code-entry page: shown for a well-formed but unknown remote code instead
115
+ * of the SPA's 404. Asks the visitor for a valid code and forwards to
116
+ * /<code>/, where the normal conceal+tunnel flow takes over — the entered
117
+ * code never lingers in a form, and a live code behaves exactly like
118
+ * opening its link for the first time.
119
+ */
120
+ enterHtml(badCode) {
121
+ const hint = badCode
122
+ ? `<div class="err">远程码 <code>${badCode}</code> 无效或宿主机未在线,请核对后重试。</div>`
123
+ : ''
124
+ return `<!DOCTYPE html>
125
+ <html lang="zh-CN"><head><meta charset="utf-8">
126
+ <meta name="viewport" content="width=device-width, initial-scale=1">
127
+ <meta name="robots" content="noindex">
128
+ <title>DeepSeek 远程访问</title>
129
+ <style>body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;background:#101114;color:#e8eaed;font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
130
+ .card{width:min(360px,90vw);background:#1a1c21;border:1px solid #2a2d34;border-radius:14px;padding:28px 26px;box-sizing:border-box}
131
+ h1{margin:0 0 6px;font-size:16px;font-weight:600}.sub{margin:0 0 18px;font-size:12.5px;color:#9aa0a6}
132
+ .row{display:flex;gap:8px}
133
+ input{flex:1;min-width:0;background:#101114;border:1px solid #2a2d34;border-radius:8px;color:#e8eaed;padding:9px 12px;font:15px ui-monospace,Menlo,monospace;letter-spacing:.12em;outline:none}
134
+ input:focus{border-color:#6b84ff}
135
+ button{background:#4a5cff;border:0;border-radius:8px;color:#fff;padding:9px 16px;font:14px inherit;cursor:pointer}
136
+ button:hover{background:#5b6bff}
137
+ .err{margin-top:14px;font-size:12.5px;color:#ff8a80;word-break:break-all}code{font-family:ui-monospace,Menlo,monospace;color:#ffb4a9}
138
+ .tip{margin-top:16px;font-size:11.5px;color:#6b7078}</style></head>
139
+ <body><div class="card"><h1>DeepSeek 远程访问</h1><p class="sub">请输入 8 位远程码以连接对应的宿主机。</p>
140
+ ${hint}
141
+ <form class="row"><input name="code" inputmode="numeric" autocomplete="off" maxlength="8" placeholder="12345678" autofocus><button type="submit">进入</button></form>
142
+ <div class="tip">远程码由宿主机属主面板提供,仅在宿主机在线时可用。</div></div>
143
+ <script>(function(){
144
+ var f=document.querySelector('form');var i=document.querySelector('input');
145
+ i.addEventListener('input',function(){i.value=i.value.replace(/\\D/g,'').slice(0,8)});
146
+ f.addEventListener('submit',function(ev){
147
+ ev.preventDefault();
148
+ var c=i.value;
149
+ if(!/^[1-9]\\d{7}$/.test(c)){i.focus();return}
150
+ location.assign('/'+c+'/');
151
+ });
152
+ i.focus();
153
+ })();</script></body></html>`
67
154
  }
68
155
 
69
156
  later(fn, ms) {
@@ -220,6 +307,7 @@ export class RelayCore {
220
307
  httpOps: new Map(),
221
308
  splices: new Map(),
222
309
  routes: [],
310
+ fileKey: undefined,
223
311
  createdAt: Date.now(),
224
312
  }
225
313
  if (existing) {
@@ -231,6 +319,7 @@ export class RelayCore {
231
319
  host.name = typeof hello.name === 'string' ? hello.name.slice(0, 80) : host.name
232
320
  host.dsh = typeof hello.dsh === 'string' ? hello.dsh.slice(0, 40) : host.dsh
233
321
  host.ip = ip
322
+ host.fileKey = fileRouteKey(hostId)
234
323
  host.tokenHash = hashToken(newToken)
235
324
  host.socket = socket
236
325
  host.lastSeen = Date.now()
@@ -353,7 +442,7 @@ export class RelayCore {
353
442
 
354
443
  offlineHtml(code) {
355
444
  return `<!DOCTYPE html>
356
- <html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="refresh" content="5"><script>try{localStorage.setItem('dsh-remote-last-code','${code}')}catch(e){}</script>
445
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="refresh" content="5"><script>(function(){try{localStorage.setItem('dsh-remote-last-code','${code}');sessionStorage.setItem('dsh-remote-last-code','${code}');if((location.search||'').indexOf('gui')===-1)history.replaceState(null,'','/')}catch(e){}})();</script>
357
446
  <meta name="viewport" content="width=device-width, initial-scale=1">
358
447
  <title>远程控制 ${code}</title>
359
448
  <style>body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#101114;color:#e8eaed;font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.big{font-size:17px}.hint{font-size:12.5px;color:#9aa0a6}code{font-family:ui-monospace,Menlo,monospace;color:#6b84ff}</style></head>
@@ -389,11 +478,162 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
389
478
  })(/* dsh-remote visitor shim */);</script>`
390
479
  }
391
480
 
392
- injectVisitorShim(html, code) {
481
+ /**
482
+ * Code concealment: remember the code, pin relative resolution to the
483
+ * tunnel prefix (history.replaceState would change how `<base href="./">`
484
+ * resolves), strip the code from the visible URL, and register the
485
+ * origin-level service worker so later visits to the bare origin restore
486
+ * the tunnel without the code ever reappearing in the address bar.
487
+ * Injected right after `<base>`; runs before app scripts.
488
+ */
489
+ concealScript(code) {
490
+ const codeJson = JSON.stringify(code)
491
+ const prefixJson = JSON.stringify(`/${code}/`)
492
+ return `<script>(function(){
493
+ window.__DSH_REMOTE_CONCEAL__=true;
494
+ try{
495
+ var C=${codeJson};
496
+ if((location.search||'').indexOf('gui')!==-1){try{sessionStorage.setItem('dsh-remote-bypass','1')}catch(e){}return}
497
+ var b=document.querySelector('base');
498
+ if(b)b.setAttribute('href',${prefixJson});
499
+ try{localStorage.setItem('dsh-remote-last-code',C)}catch(e){}
500
+ try{sessionStorage.setItem('dsh-remote-last-code',C)}catch(e){}
501
+ try{history.replaceState(null,'','/')}catch(e){}
502
+ if(navigator.serviceWorker&&window.isSecureContext){
503
+ navigator.serviceWorker.register('/dsh-remote-sw.js?code='+C,{scope:'/'}).catch(function(){})
504
+ }
505
+ }catch(e){}
506
+ })(/* dsh-remote conceal */);</script>`
507
+ }
508
+
509
+ /**
510
+ * Visitor-side "download file" menu patch. The host GUI's file menu
511
+ * (OpenTargetButton) only hands paths to the host desktop; a remote
512
+ * visitor has no way to save the file locally. This script watches for
513
+ * that menu opening, prepends a "下载文件" item (styled after the native
514
+ * menu rows) and serves the file through the host's `/dsh-remote-file`
515
+ * route via the tunnel.
516
+ *
517
+ * The absolute path comes from the preview header's PathLabel
518
+ * (`[data-path-label]` holds it in `title`); as a fallback we tap mux
519
+ * frames for the last path the app requested (openWorkspacePath /
520
+ * workspacePathApplications payloads carry `"path":"…"`).
521
+ * Injected last, after the visitor shim (its WebSocket wrapper stays).
522
+ */
523
+ downloadMenuScript(code, key) {
524
+ const codeJson = JSON.stringify(code)
525
+ const keyJson = JSON.stringify(String(key ?? ''))
526
+ return `<script>(function(){
527
+ window.__DSH_REMOTE_DOWNLOAD__=true;
528
+ try{
529
+ var C=${codeJson};var K=${keyJson};
530
+ var M=/显示文件位置|Show file location|ファイルの場所| 파일 위치|Открыть расположение/;
531
+ var PATH_RE=/^([\\/~]|[A-Za-z]:[\\\\\\/])/;
532
+ function isPath(s){return typeof s==='string'&&s.length>1&&s.length<1024&&PATH_RE.test(s)&&s.indexOf('/')>-1}
533
+ var lastMux=null;
534
+ function notePath(p){if(isPath(p))lastMux={path:p,at:Date.now()}}
535
+ var OW=window.WebSocket;
536
+ window.WebSocket=class extends OW{constructor(u,p){super(u,p);var self=this;var s=super.send;
537
+ // tap outgoing mux frames for file paths (fallback when no PathLabel DOM)
538
+ this.send=function(data){
539
+ try{
540
+ if(typeof data==='string'&&data.indexOf('"path"')>-1){
541
+ var m=data.match(/"path"\\s*:\\s*"((?:[^"\\\\]|\\\\(?:u[0-9a-fA-F]{4}|.))*)"/);
542
+ if(m){try{notePath(JSON.parse('"'+m[1]+'"'))}catch(e){}}
543
+ }
544
+ }catch(e){}
545
+ return s.call(self,data)}
546
+ }};
547
+ function base(){return location.pathname.replace(new RegExp('^/'+C+'(/|$)'),'/')==='/'
548
+ ? '/'+C : location.pathname.split('/').slice(0,2).join('/')}
549
+ function download(path){
550
+ var url=base()+'/dsh-remote-file?path='+encodeURIComponent(path);
551
+ return fetch(url,{credentials:'omit',headers:{'x-dsh-remote-key':K}}).then(function(r){
552
+ if(!r.ok)throw new Error('HTTP '+r.status);
553
+ var cd=r.headers.get('content-disposition')||'';
554
+ var m=cd.match(/filename\\*=UTF-8''([^;]+)/i);
555
+ var name=m?decodeURIComponent(m[1]):(path.split('/').pop()||'download');
556
+ return r.blob().then(function(b){
557
+ var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;
558
+ document.body.appendChild(a);a.click();a.remove();
559
+ setTimeout(function(){URL.revokeObjectURL(a.href)},30000);
560
+ });
561
+ });
562
+ }
563
+ function findLabel(el){
564
+ for(var n=el,i=0;n&&i<8;i++,n=n.parentElement){
565
+ var lbl=n.querySelector&&n.querySelector('[data-path-label]');
566
+ if(lbl){var t=lbl.getAttribute('title')||lbl.textContent;if(isPath(t))return t}
567
+ }
568
+ return null;
569
+ }
570
+ function patch(menu){
571
+ if(menu.getAttribute('data-dsh-remote-dl'))return;
572
+ menu.setAttribute('data-dsh-remote-dl','1');
573
+ var items=menu.querySelectorAll('[role=menuitem]');
574
+ if(!items.length)return;
575
+ var itemsArr=Array.prototype.slice.call(items);
576
+ // Only file menus: the native reveal item, or a menu anchored by the file target.
577
+ var trigger=null;
578
+ var mid=menu.id||'';
579
+ if(mid){var cands=document.querySelectorAll('[aria-controls]');
580
+ for(var i=0;i<cands.length;i++){var v=(cands[i].getAttribute('aria-controls')||'').split(/\\s+/);
581
+ if(v.indexOf(mid)>-1){trigger=cands[i];break}}}
582
+ var hasReveal=itemsArr.some(function(it){return M.test(it.textContent||'')});
583
+ var isFile=hasReveal||(trigger&&trigger.closest&&trigger.closest('[data-open-target="file"]'));
584
+ if(!isFile)return;
585
+ var first=itemsArr[0];
586
+ var btn=document.createElement('button');
587
+ btn.setAttribute('role','menuitem');
588
+ btn.setAttribute('type','button');
589
+ btn.className=first.className||'';
590
+ btn.style.cssText='all:unset;display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;cursor:pointer;color:inherit;font:inherit;';
591
+ for(var a=0;a<first.attributes.length;a++){var at=first.attributes[a];
592
+ if(at.name!=='role'&&at.name!=='type'&&at.name!=='disabled'&&at.name.indexOf('data-')!==0&&!/^(id|aria-(labelledby|describedby))$/.test(at.name)){try{btn.setAttribute(at.name,at.value)}catch(e){}}}
593
+ btn.setAttribute('data-dsh-remote-dl-item','1');
594
+ var zh=(document.documentElement.lang||'').toLowerCase().indexOf('zh')===0;
595
+ btn.innerHTML='<span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;opacity:.85">'
596
+ +'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 2.2v7.6m0 0 2.8-2.8M8 9.8 5.2 7M3 12.6h10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
597
+ +'</span><span></span>';
598
+ btn.lastElementChild.textContent=zh?'下载文件':'Download file';
599
+ btn.addEventListener('click',function(ev){
600
+ ev.preventDefault();ev.stopPropagation();
601
+ var path=findLabel(trigger||first)||((lastMux&&Date.now()-(lastMux.at||0)<120000)?lastMux.path:null);
602
+ if(!path){
603
+ var t=btn.lastElementChild;var old=t.textContent;
604
+ t.textContent=zh?'无法定位文件':'Cannot locate file';
605
+ setTimeout(function(){t.textContent=old},1200);
606
+ return;
607
+ }
608
+ download(path).catch(function(){});
609
+ });
610
+ first.parentNode.insertBefore(btn,first);
611
+ }
612
+ var mo=new MutationObserver(function(muts){
613
+ for(var i=0;i<muts.length;i++){var add=muts[i].addedNodes;
614
+ for(var j=0;j<add.length;j++){var n=add[j];
615
+ if(!n||n.nodeType!==1)continue;
616
+ var menus=n.matches&&n.matches('[role=menu]')?[n]:(n.querySelectorAll?n.querySelectorAll('[role=menu]'):[]);
617
+ for(var k=0;k<menus.length;k++){try{patch(menus[k])}catch(e){}}
618
+ }}
619
+ });
620
+ function start(){try{mo.observe(document.body,{childList:true,subtree:true})}catch(e){}}
621
+ if(document.body)start();else document.addEventListener('DOMContentLoaded',start);
622
+ // Menus already open before the observer started.
623
+ try{document.querySelectorAll('[role=menu]').forEach(patch)}catch(e){}
624
+ }catch(e){}
625
+ })(/* dsh-remote download menu */);</script>`
626
+ }
627
+
628
+ injectVisitorShim(html, code, fileKey) {
393
629
  if (typeof html !== 'string' || html.length === 0) return html
394
- const shim = this.visitorShimScript(code)
395
- if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + shim)
396
- return shim + html
630
+ let scripts = this.concealScript(code) + this.visitorShimScript(code)
631
+ if (typeof fileKey === 'string' && fileKey.length > 0) {
632
+ scripts += this.downloadMenuScript(code, fileKey)
633
+ }
634
+ if (/<base[^>]*>/i.test(html)) return html.replace(/<base[^>]*>/i, (m) => m + scripts)
635
+ if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + scripts)
636
+ return scripts + html
397
637
  }
398
638
 
399
639
  proxyHttp(req, res, code, host) {
@@ -552,7 +792,7 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
552
792
  op.done = true
553
793
  try {
554
794
  if (op.htmlInject) {
555
- const html = this.injectVisitorShim(Buffer.concat(op.parts).toString('utf8'), host.code)
795
+ const html = this.injectVisitorShim(Buffer.concat(op.parts).toString('utf8'), host.code, host.fileKey)
556
796
  op.res.end(html)
557
797
  } else {
558
798
  op.res.end()
@@ -797,6 +1037,8 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
797
1037
 
798
1038
  detachAll() {
799
1039
  this.closed = true
1040
+ for (const dispose of this.enterDisposers) { try { dispose() } catch { /* gone */ } }
1041
+ this.enterDisposers = []
800
1042
  for (const timer of this.timers) clearTimeout(timer)
801
1043
  this.timers.clear()
802
1044
  for (const hostId of [...this.hosts.keys()]) this.removeHost(hostId, 'relay shutting down')
@@ -821,3 +1063,71 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
821
1063
  }
822
1064
 
823
1065
  export { PROTOCOL_VERSION }
1066
+
1067
+ /**
1068
+ * Origin-level service worker served at /dsh-remote-sw.js. It reads its own
1069
+ * scriptURL query for the code and, from then on, intercepts navigations to
1070
+ * the bare origin (which belong to the relay's authenticated GUI and 401 for
1071
+ * visitors) and serves the tunnel under /<code>/ instead — the address bar
1072
+ * keeps showing the bare origin. Carriers of the relay server's own session
1073
+ * cookie (`dsh-auth-…`) pass through untouched, so the relay owner's GUI is
1074
+ * never hijacked, and `?gui` is a manual escape.
1075
+ */
1076
+ export function serviceWorkerScript() {
1077
+ return `/* dsh-remote visitor restore worker */
1078
+ var CODE='';
1079
+ try{CODE=new URL(self.location.href).searchParams.get('code')||''}catch(e){}
1080
+ var CODE_RE=/^\\/([1-9]\\d{7})(\\/|$)/;
1081
+ self.addEventListener('install',function(e){self.skipWaiting()});
1082
+ self.addEventListener('activate',function(e){e.waitUntil(self.clients.claim())});
1083
+ self.addEventListener('fetch',function(event){
1084
+ try{
1085
+ var req=event.request;
1086
+ if(req.method!=='GET')return;
1087
+ if(req.mode!=='navigate')return;
1088
+ var url=new URL(req.url);
1089
+ if(url.origin!==self.location.origin)return;
1090
+ if((url.search||'').indexOf('gui')!==-1)return;
1091
+ if((req.headers.get('cookie')||'').indexOf('dsh-auth-')!==-1)return;
1092
+ /* Unknown-code guard: an 8-digit path that answers 404 (no live host)
1093
+ gets the code-entry dialog instead of the bare 404 page. */
1094
+ var cm=url.pathname.match(CODE_RE);
1095
+ if(cm){
1096
+ event.respondWith((async function(){
1097
+ var probe;
1098
+ try{probe=await fetch(new Request(req.url,{method:'HEAD',cache:'no-store'}))}catch(e){return fetch(req)}
1099
+ if(probe&&probe.status===404){
1100
+ try{return await fetch('/dsh-remote-enter?code='+cm[1],{cache:'no-store'})}catch(e){return fetch(req)}
1101
+ }
1102
+ return fetch(req);
1103
+ })());
1104
+ return;
1105
+ }
1106
+ if(url.pathname!=='/'&&url.pathname!=='')return;
1107
+ if(!/^[1-9]\\d{7}$/.test(CODE))return;
1108
+ event.respondWith((async function(){
1109
+ var up;
1110
+ try{up=await fetch('/'+CODE+'/',{cache:'no-store'})}catch(e){return}
1111
+ if(up.status===404){
1112
+ try{return await fetch('/dsh-remote-enter?code='+CODE,{cache:'no-store'})}catch(e){return fetch(req)}
1113
+ }
1114
+ var ct=up.headers.get('content-type')||'';
1115
+ if(ct.indexOf('text/html')===-1)return up;
1116
+ var text=await up.text();
1117
+ if(text.indexOf('__DSH_REMOTE_VISITOR__')===-1)return fetch(req);
1118
+ return new Response(text,{status:up.status,statusText:up.statusText,headers:up.headers});
1119
+ })());
1120
+ }catch(e){}
1121
+ });
1122
+ `
1123
+ }
1124
+
1125
+ export function serveServiceWorker(req, res) {
1126
+ res.writeHead(200, {
1127
+ 'Content-Type': 'text/javascript; charset=utf-8',
1128
+ 'Cache-Control': 'no-store',
1129
+ 'Service-Worker-Allowed': '/',
1130
+ })
1131
+ res.end(serviceWorkerScript())
1132
+ }
1133
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillpie/dsh-remote",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Remote control for DeepSeek Harness: reach any host by its 8-digit code through a relay origin such as dsh.skillpie.cn.",
5
5
  "type": "module",
6
6
  "license": "MIT",