@skillpie/dsh-remote 1.0.0 → 1.1.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,18 @@ 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).
331
+ if (mode === 'gui' && (direct || relayFaceEnabled)) {
332
+ disposers.push(webServer.register({
333
+ kind: 'exact',
334
+ path: '/dsh-remote-sw.js',
335
+ handler: (req, res) => { try { serveServiceWorker(req, res) } catch { try { res.destroy() } catch { /* gone */ } } },
336
+ }))
337
+ logLine('sw', '访客恢复 Worker 已挂载(/dsh-remote-sw.js)')
338
+ }
339
+
340
+ // 8. Relay face (server deployments only).
320
341
  if (relayFaceEnabled) {
321
342
  const relayCore = new RelayCore({
322
343
  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
@@ -220,6 +221,7 @@ export class RelayCore {
220
221
  httpOps: new Map(),
221
222
  splices: new Map(),
222
223
  routes: [],
224
+ fileKey: undefined,
223
225
  createdAt: Date.now(),
224
226
  }
225
227
  if (existing) {
@@ -231,6 +233,7 @@ export class RelayCore {
231
233
  host.name = typeof hello.name === 'string' ? hello.name.slice(0, 80) : host.name
232
234
  host.dsh = typeof hello.dsh === 'string' ? hello.dsh.slice(0, 40) : host.dsh
233
235
  host.ip = ip
236
+ host.fileKey = fileRouteKey(hostId)
234
237
  host.tokenHash = hashToken(newToken)
235
238
  host.socket = socket
236
239
  host.lastSeen = Date.now()
@@ -353,7 +356,7 @@ export class RelayCore {
353
356
 
354
357
  offlineHtml(code) {
355
358
  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>
359
+ <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
360
  <meta name="viewport" content="width=device-width, initial-scale=1">
358
361
  <title>远程控制 ${code}</title>
359
362
  <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 +392,162 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
389
392
  })(/* dsh-remote visitor shim */);</script>`
390
393
  }
391
394
 
392
- injectVisitorShim(html, code) {
395
+ /**
396
+ * Code concealment: remember the code, pin relative resolution to the
397
+ * tunnel prefix (history.replaceState would change how `<base href="./">`
398
+ * resolves), strip the code from the visible URL, and register the
399
+ * origin-level service worker so later visits to the bare origin restore
400
+ * the tunnel without the code ever reappearing in the address bar.
401
+ * Injected right after `<base>`; runs before app scripts.
402
+ */
403
+ concealScript(code) {
404
+ const codeJson = JSON.stringify(code)
405
+ const prefixJson = JSON.stringify(`/${code}/`)
406
+ return `<script>(function(){
407
+ window.__DSH_REMOTE_CONCEAL__=true;
408
+ try{
409
+ var C=${codeJson};
410
+ if((location.search||'').indexOf('gui')!==-1){try{sessionStorage.setItem('dsh-remote-bypass','1')}catch(e){}return}
411
+ var b=document.querySelector('base');
412
+ if(b)b.setAttribute('href',${prefixJson});
413
+ try{localStorage.setItem('dsh-remote-last-code',C)}catch(e){}
414
+ try{sessionStorage.setItem('dsh-remote-last-code',C)}catch(e){}
415
+ try{history.replaceState(null,'','/')}catch(e){}
416
+ if(navigator.serviceWorker&&window.isSecureContext){
417
+ navigator.serviceWorker.register('/dsh-remote-sw.js?code='+C,{scope:'/'}).catch(function(){})
418
+ }
419
+ }catch(e){}
420
+ })(/* dsh-remote conceal */);</script>`
421
+ }
422
+
423
+ /**
424
+ * Visitor-side "download file" menu patch. The host GUI's file menu
425
+ * (OpenTargetButton) only hands paths to the host desktop; a remote
426
+ * visitor has no way to save the file locally. This script watches for
427
+ * that menu opening, prepends a "下载文件" item (styled after the native
428
+ * menu rows) and serves the file through the host's `/dsh-remote-file`
429
+ * route via the tunnel.
430
+ *
431
+ * The absolute path comes from the preview header's PathLabel
432
+ * (`[data-path-label]` holds it in `title`); as a fallback we tap mux
433
+ * frames for the last path the app requested (openWorkspacePath /
434
+ * workspacePathApplications payloads carry `"path":"…"`).
435
+ * Injected last, after the visitor shim (its WebSocket wrapper stays).
436
+ */
437
+ downloadMenuScript(code, key) {
438
+ const codeJson = JSON.stringify(code)
439
+ const keyJson = JSON.stringify(String(key ?? ''))
440
+ return `<script>(function(){
441
+ window.__DSH_REMOTE_DOWNLOAD__=true;
442
+ try{
443
+ var C=${codeJson};var K=${keyJson};
444
+ var M=/显示文件位置|Show file location|ファイルの場所| 파일 위치|Открыть расположение/;
445
+ var PATH_RE=/^([\\/~]|[A-Za-z]:[\\\\\\/])/;
446
+ function isPath(s){return typeof s==='string'&&s.length>1&&s.length<1024&&PATH_RE.test(s)&&s.indexOf('/')>-1}
447
+ var lastMux=null;
448
+ function notePath(p){if(isPath(p))lastMux={path:p,at:Date.now()}}
449
+ var OW=window.WebSocket;
450
+ window.WebSocket=class extends OW{constructor(u,p){super(u,p);var self=this;var s=super.send;
451
+ // tap outgoing mux frames for file paths (fallback when no PathLabel DOM)
452
+ this.send=function(data){
453
+ try{
454
+ if(typeof data==='string'&&data.indexOf('"path"')>-1){
455
+ var m=data.match(/"path"\\s*:\\s*"((?:[^"\\\\]|\\\\(?:u[0-9a-fA-F]{4}|.))*)"/);
456
+ if(m){try{notePath(JSON.parse('"'+m[1]+'"'))}catch(e){}}
457
+ }
458
+ }catch(e){}
459
+ return s.call(self,data)}
460
+ }};
461
+ function base(){return location.pathname.replace(new RegExp('^/'+C+'(/|$)'),'/')==='/'
462
+ ? '/'+C : location.pathname.split('/').slice(0,2).join('/')}
463
+ function download(path){
464
+ var url=base()+'/dsh-remote-file?path='+encodeURIComponent(path);
465
+ return fetch(url,{credentials:'omit',headers:{'x-dsh-remote-key':K}}).then(function(r){
466
+ if(!r.ok)throw new Error('HTTP '+r.status);
467
+ var cd=r.headers.get('content-disposition')||'';
468
+ var m=cd.match(/filename\\*=UTF-8''([^;]+)/i);
469
+ var name=m?decodeURIComponent(m[1]):(path.split('/').pop()||'download');
470
+ return r.blob().then(function(b){
471
+ var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;
472
+ document.body.appendChild(a);a.click();a.remove();
473
+ setTimeout(function(){URL.revokeObjectURL(a.href)},30000);
474
+ });
475
+ });
476
+ }
477
+ function findLabel(el){
478
+ for(var n=el,i=0;n&&i<8;i++,n=n.parentElement){
479
+ var lbl=n.querySelector&&n.querySelector('[data-path-label]');
480
+ if(lbl){var t=lbl.getAttribute('title')||lbl.textContent;if(isPath(t))return t}
481
+ }
482
+ return null;
483
+ }
484
+ function patch(menu){
485
+ if(menu.getAttribute('data-dsh-remote-dl'))return;
486
+ menu.setAttribute('data-dsh-remote-dl','1');
487
+ var items=menu.querySelectorAll('[role=menuitem]');
488
+ if(!items.length)return;
489
+ var itemsArr=Array.prototype.slice.call(items);
490
+ // Only file menus: the native reveal item, or a menu anchored by the file target.
491
+ var trigger=null;
492
+ var mid=menu.id||'';
493
+ if(mid){var cands=document.querySelectorAll('[aria-controls]');
494
+ for(var i=0;i<cands.length;i++){var v=(cands[i].getAttribute('aria-controls')||'').split(/\\s+/);
495
+ if(v.indexOf(mid)>-1){trigger=cands[i];break}}}
496
+ var hasReveal=itemsArr.some(function(it){return M.test(it.textContent||'')});
497
+ var isFile=hasReveal||(trigger&&trigger.closest&&trigger.closest('[data-open-target="file"]'));
498
+ if(!isFile)return;
499
+ var first=itemsArr[0];
500
+ var btn=document.createElement('button');
501
+ btn.setAttribute('role','menuitem');
502
+ btn.setAttribute('type','button');
503
+ btn.className=first.className||'';
504
+ btn.style.cssText='all:unset;display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;cursor:pointer;color:inherit;font:inherit;';
505
+ for(var a=0;a<first.attributes.length;a++){var at=first.attributes[a];
506
+ 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){}}}
507
+ btn.setAttribute('data-dsh-remote-dl-item','1');
508
+ var zh=(document.documentElement.lang||'').toLowerCase().indexOf('zh')===0;
509
+ btn.innerHTML='<span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;opacity:.85">'
510
+ +'<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>'
511
+ +'</span><span></span>';
512
+ btn.lastElementChild.textContent=zh?'下载文件':'Download file';
513
+ btn.addEventListener('click',function(ev){
514
+ ev.preventDefault();ev.stopPropagation();
515
+ var path=findLabel(trigger||first)||((lastMux&&Date.now()-(lastMux.at||0)<120000)?lastMux.path:null);
516
+ if(!path){
517
+ var t=btn.lastElementChild;var old=t.textContent;
518
+ t.textContent=zh?'无法定位文件':'Cannot locate file';
519
+ setTimeout(function(){t.textContent=old},1200);
520
+ return;
521
+ }
522
+ download(path).catch(function(){});
523
+ });
524
+ first.parentNode.insertBefore(btn,first);
525
+ }
526
+ var mo=new MutationObserver(function(muts){
527
+ for(var i=0;i<muts.length;i++){var add=muts[i].addedNodes;
528
+ for(var j=0;j<add.length;j++){var n=add[j];
529
+ if(!n||n.nodeType!==1)continue;
530
+ var menus=n.matches&&n.matches('[role=menu]')?[n]:(n.querySelectorAll?n.querySelectorAll('[role=menu]'):[]);
531
+ for(var k=0;k<menus.length;k++){try{patch(menus[k])}catch(e){}}
532
+ }}
533
+ });
534
+ function start(){try{mo.observe(document.body,{childList:true,subtree:true})}catch(e){}}
535
+ if(document.body)start();else document.addEventListener('DOMContentLoaded',start);
536
+ // Menus already open before the observer started.
537
+ try{document.querySelectorAll('[role=menu]').forEach(patch)}catch(e){}
538
+ }catch(e){}
539
+ })(/* dsh-remote download menu */);</script>`
540
+ }
541
+
542
+ injectVisitorShim(html, code, fileKey) {
393
543
  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
544
+ let scripts = this.concealScript(code) + this.visitorShimScript(code)
545
+ if (typeof fileKey === 'string' && fileKey.length > 0) {
546
+ scripts += this.downloadMenuScript(code, fileKey)
547
+ }
548
+ if (/<base[^>]*>/i.test(html)) return html.replace(/<base[^>]*>/i, (m) => m + scripts)
549
+ if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + scripts)
550
+ return scripts + html
397
551
  }
398
552
 
399
553
  proxyHttp(req, res, code, host) {
@@ -552,7 +706,7 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
552
706
  op.done = true
553
707
  try {
554
708
  if (op.htmlInject) {
555
- const html = this.injectVisitorShim(Buffer.concat(op.parts).toString('utf8'), host.code)
709
+ const html = this.injectVisitorShim(Buffer.concat(op.parts).toString('utf8'), host.code, host.fileKey)
556
710
  op.res.end(html)
557
711
  } else {
558
712
  op.res.end()
@@ -821,3 +975,51 @@ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?
821
975
  }
822
976
 
823
977
  export { PROTOCOL_VERSION }
978
+
979
+ /**
980
+ * Origin-level service worker served at /dsh-remote-sw.js. It reads its own
981
+ * scriptURL query for the code and, from then on, intercepts navigations to
982
+ * the bare origin (which belong to the relay's authenticated GUI and 401 for
983
+ * visitors) and serves the tunnel under /<code>/ instead — the address bar
984
+ * keeps showing the bare origin. Carriers of the relay server's own session
985
+ * cookie (`dsh-auth-…`) pass through untouched, so the relay owner's GUI is
986
+ * never hijacked, and `?gui` is a manual escape.
987
+ */
988
+ export function serviceWorkerScript() {
989
+ return `/* dsh-remote visitor restore worker */
990
+ var CODE='';
991
+ try{CODE=new URL(self.location.href).searchParams.get('code')||''}catch(e){}
992
+ self.addEventListener('install',function(e){self.skipWaiting()});
993
+ self.addEventListener('activate',function(e){e.waitUntil(self.clients.claim())});
994
+ self.addEventListener('fetch',function(event){
995
+ try{
996
+ var req=event.request;
997
+ if(req.mode!=='navigate')return;
998
+ var url=new URL(req.url);
999
+ if(url.origin!==self.location.origin)return;
1000
+ if((url.search||'').indexOf('gui')!==-1)return;
1001
+ if((req.headers.get('cookie')||'').indexOf('dsh-auth-')!==-1)return;
1002
+ if(url.pathname!=='/'&&url.pathname!=='')return;
1003
+ if(!/^[1-9]\\d{7}$/.test(CODE))return;
1004
+ event.respondWith((async function(){
1005
+ var up;
1006
+ try{up=await fetch('/'+CODE+'/',{cache:'no-store'})}catch(e){return}
1007
+ var ct=up.headers.get('content-type')||'';
1008
+ if(ct.indexOf('text/html')===-1)return up;
1009
+ var text=await up.text();
1010
+ if(text.indexOf('__DSH_REMOTE_VISITOR__')===-1)return fetch(req);
1011
+ return new Response(text,{status:up.status,statusText:up.statusText,headers:up.headers});
1012
+ })());
1013
+ }catch(e){}
1014
+ });
1015
+ `
1016
+ }
1017
+
1018
+ export function serveServiceWorker(req, res) {
1019
+ res.writeHead(200, {
1020
+ 'Content-Type': 'text/javascript; charset=utf-8',
1021
+ 'Cache-Control': 'no-store',
1022
+ 'Service-Worker-Allowed': '/',
1023
+ })
1024
+ res.end(serviceWorkerScript())
1025
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillpie/dsh-remote",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",