dsh-desktop-embed 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.
Files changed (3) hide show
  1. package/README.md +38 -0
  2. package/index.js +385 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -69,6 +69,44 @@ curl -s http://127.0.0.1:8799/embed/setup -H 'sec-fetch-site: none' | grep -o 'n
69
69
 
70
70
  手机端更直接:点按钮 → 填两个值 → 页面里出现电脑版界面,且**地址栏仍是 127.0.0.1**。
71
71
 
72
+ ## 为什么第一次打开慢,以及 1.1.0 做了什么
73
+
74
+ 手机上第一次打开要等一两分钟,主要不是「电脑慢」,是这条链路上重复了太多工作:
75
+
76
+ - 每取一个资源都新建一条 TCP + TLS 连接;界面的框架资源有几十个,手机上每次握手几百毫秒,串起来就是一分钟。
77
+ - 入口页引用的主 JS 单文件约 940 KB,从中继直取实测 **6.05 秒**。
78
+ - 用户在点按钮之后才开始取,前面这段时间完全是干等。
79
+
80
+ 1.1.0 加了四件事:
81
+
82
+ | 改动 | 效果(同一台中继上实测) |
83
+ |---|---|
84
+ | 连接池(keep-alive,`maxSockets 16`) | 同一台中继的请求复用连接,不再每次重新握手 |
85
+ | 启动时预热:先把入口页和它引用的 `/assets`、`/plugins` 资源拉进内存缓存 | 点按钮时框架资源已经在本地 |
86
+ | 静态资源缓存(按内容哈希为键,上限 96 MB)+ 并发请求合并 | 主 JS `941436 B`:中继直取 **6.05 s** → 命中缓存 **1.7 ms** |
87
+ | `/embed` 加载进度层(步骤文案 + 秒表 + 「已预热 n/m」) | 不再是白屏干等;实测「页面载入 → 界面就绪」约 **4.5 s**(缓存热) |
88
+
89
+ 预热是**自动**的:插件启动时若已有配置就立即开始,保存设置后也会再跑一次。缓存只在内存里,DSH 重启就没了——重启后的第一次打开会慢一些,之后又是快的。
90
+
91
+ 排查「为什么还是慢」:
92
+
93
+ ```bash
94
+ curl http://127.0.0.1:<手机 DSH 端口>/api/dsh-embed/status
95
+ ```
96
+
97
+ 返回里的 `cache.entries` / `cache.bytes` / `warm`(资产数、成功数、耗时)就是预热结果;`warm.ok=false` 时看 `warm.error`。
98
+
99
+ ## 手机上的排版
100
+
101
+ iframe 与外层不同源(端口不同),外层改不了里面的 DOM,所以排版修补放在代理这一层:给经过的入口页注入一张样式表,规则全部关在 `@media (max-width: 1023px) and (pointer: coarse)` 里——
102
+
103
+ - 按钮、`[role=button|tab|menuitem|option]` 最小 40×40,输入框最小高 40 且字号 16px(小于 16px 时 iOS / 部分安卓会放大页面)
104
+ - 侧栏 / 抽屉最大 `88vw`,不再盖住全部内容
105
+ - 表格横向滚动而不是撑破页面,图片视频限宽,正文禁止横向溢出
106
+ - 文本域底部留 `env(safe-area-inset-bottom)`,软键盘弹起时输入框不被顶掉
107
+
108
+ 桌面上这条 media query 不匹配,所以不影响电脑端观感。
109
+
72
110
  ## 已知限制
73
111
 
74
112
  1. **`Secure` cookie 不能用明文 http 送过去。** 中继的 `dsh_pair` 带 `Secure`;手机 → 中继这一段如果是 `https://`(中继本身就是 https,正常情况都满足),代理拿着 cookie 发请求没问题。但如果你的 `bridgeUrl` 指向一个 http 桥,插件只是拿它当「取链接的信使」,不拿它传 cookie,所以照样可用。
package/index.js CHANGED
@@ -41,10 +41,10 @@
41
41
  * cookie 只留在内存里,进程重启就重铸一枚;设备会话本身仍然沿用
42
42
  * `~/.dsh/remote-web-ui-devices.json`,重启 `dsh web` 不会掉。
43
43
  */
44
- import { randomBytes } from 'node:crypto'
44
+ import { createHash, randomBytes } from 'node:crypto'
45
45
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
46
- import { createServer, request as httpRequest } from 'node:http'
47
- import { request as httpsRequest } from 'node:https'
46
+ import { Agent as HttpAgent, createServer, request as httpRequest } from 'node:http'
47
+ import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'
48
48
  import { createRequire } from 'node:module'
49
49
  import { homedir } from 'node:os'
50
50
  import { dirname, join, resolve } from 'node:path'
@@ -60,6 +60,23 @@ const CONFIG_FILE_NAME = 'dsh-embed.json'
60
60
  const GATE_COOKIE = 'dsh_embed'
61
61
  const TARGET_COOKIE = 'dsh_pair'
62
62
 
63
+ /**
64
+ * 这两条是「第一次打开要等一两分钟」的主要来源:每个资源都新建一条 TCP+TLS
65
+ * 连接,手机上每次握手几百毫秒,几十个资源排下来就是一分钟。改成 keep-alive
66
+ * 池以后同一台中继上的请求复用同一条连接。
67
+ */
68
+ const httpAgent = new HttpAgent({ keepAlive: true, keepAliveMsecs: 20000, maxSockets: 16 })
69
+ const httpsAgent = new HttpsAgent({ keepAlive: true, keepAliveMsecs: 20000, maxSockets: 16 })
70
+
71
+ /** 静态资源(带内容哈希的文件名)可以长期缓存;入口页 5 分钟内有效。 */
72
+ const MAX_CACHE_ENTRY = 4 * 1024 * 1024
73
+ const MAX_CACHE_BYTES = 96 * 1024 * 1024
74
+ const ENTRY_TTL_MS = 5 * 60 * 1000
75
+
76
+ function agentFor(target) {
77
+ return target.protocol === 'https:' ? httpsAgent : httpAgent
78
+ }
79
+
63
80
  /**
64
81
  * 定位随 harness 一起安装的 schemastery;找不到就不导出 Config,
65
82
  * 插件仍然可以用文件 / 环境变量配置。裸 `import` 在插件目录里解析不到,
@@ -168,6 +185,11 @@ function createState() {
168
185
  actualPort: 0,
169
186
  gate: randomBytes(8).toString('hex'),
170
187
  minting: null,
188
+ cache: new Map(),
189
+ cacheBytes: 0,
190
+ inflight: new Map(),
191
+ warming: null,
192
+ warm: null,
171
193
  }
172
194
  }
173
195
 
@@ -257,6 +279,119 @@ function ensureSession(state, cfg) {
257
279
  return state.minting
258
280
  }
259
281
 
282
+ /** 直接向中继取一份(预热专用,不走浏览器那一层)。 */
283
+ function fetchThrough(state, path, method = 'GET') {
284
+ return new Promise((resolvePromise, rejectPromise) => {
285
+ const target = new URL(path, state.relay)
286
+ const mod = target.protocol === 'https:' ? httpsRequest : httpRequest
287
+ const req = mod(
288
+ {
289
+ protocol: target.protocol,
290
+ hostname: target.hostname,
291
+ port: target.port || undefined,
292
+ method,
293
+ path: upstreamPath(path),
294
+ headers: {
295
+ host: target.host,
296
+ cookie: state.cookie,
297
+ accept: '*/*',
298
+ 'accept-encoding': 'identity',
299
+ 'user-agent': 'dsh-embed-prewarm/1.1',
300
+ },
301
+ agent: agentFor(target),
302
+ },
303
+ (up) => {
304
+ const chunks = []
305
+ let size = 0
306
+ up.on('data', (chunk) => {
307
+ size += chunk.length
308
+ if (size > MAX_CACHE_ENTRY * 2) {
309
+ up.destroy()
310
+ rejectPromise(new Error('响应超过预热上限'))
311
+ return
312
+ }
313
+ chunks.push(chunk)
314
+ })
315
+ up.on('end', () =>
316
+ resolvePromise({ status: up.statusCode ?? 0, type: String(up.headers['content-type'] || ''), body: Buffer.concat(chunks) }),
317
+ )
318
+ up.on('error', rejectPromise)
319
+ },
320
+ )
321
+ req.on('error', rejectPromise)
322
+ req.end()
323
+ })
324
+ }
325
+
326
+ /** 从入口页里挑出要预热的资源(只认本地的 /assets、/plugins)。 */
327
+ function extractAssets(html) {
328
+ const out = []
329
+ const seen = new Set()
330
+ const re = /(?:src|href)\s*=\s*["']([^"']+)["']/g
331
+ let match
332
+ while ((match = re.exec(html))) {
333
+ let value = match[1].trim()
334
+ if (!value || value.startsWith('data:') || value.startsWith('http://') || value.startsWith('https://')) continue
335
+ if (value.startsWith('./')) value = value.slice(1)
336
+ // 入口页里有两种写法:`./assets/x.js` 和裸相对的 `plugins/??a,b,c`
337
+ if (!value.startsWith('/') && (value.startsWith('assets/') || value.startsWith('plugins/'))) value = `/${value}`
338
+ if (!value.startsWith('/assets/') && !value.startsWith('/plugins/')) continue
339
+ if (seen.has(value)) continue
340
+ seen.add(value)
341
+ out.push(value)
342
+ }
343
+ return out
344
+ }
345
+
346
+ /**
347
+ * 预热:用户点按钮之前,先把入口页和它引用的资源拉进内存缓存。
348
+ * 这样 iframe 真正启动时,界面框架的那几十个请求全部命中本地缓存。
349
+ */
350
+ function prewarm(state, cfg) {
351
+ if (state.warming) return state.warming
352
+ state.warming = (async () => {
353
+ const started = Date.now()
354
+ const report = { ok: false, assets: 0, fetched: 0, bytes: 0, ms: 0, at: Date.now(), error: '' }
355
+ try {
356
+ await ensureSession(state, cfg)
357
+ const entry = await fetchThrough(state, ENTRY_PATH)
358
+ if (entry.status !== 200) throw new Error(`入口页 HTTP ${entry.status}`)
359
+ cacheSet(state, ENTRY_PATH, 'text/html; charset=utf-8', entry.body)
360
+ report.bytes += entry.body.length
361
+ const list = extractAssets(entry.body.toString('utf8')).slice(0, 32)
362
+ report.assets = list.length
363
+ let cursor = 0
364
+ const worker = async () => {
365
+ while (cursor < list.length) {
366
+ const item = list[cursor++]
367
+ if (cacheGet(state, item)) {
368
+ report.fetched += 1
369
+ continue
370
+ }
371
+ try {
372
+ const got = await fetchThrough(state, item)
373
+ if (got.status === 200) {
374
+ cacheSet(state, item, got.type, got.body)
375
+ report.bytes += got.body.length
376
+ report.fetched += 1
377
+ }
378
+ } catch {}
379
+ }
380
+ }
381
+ await Promise.all([worker(), worker(), worker(), worker()])
382
+ report.ok = true
383
+ } catch (error) {
384
+ report.error = error?.message ?? String(error)
385
+ state.lastError = report.error
386
+ } finally {
387
+ report.ms = Date.now() - started
388
+ state.warm = report
389
+ state.warming = null
390
+ }
391
+ })()
392
+ return state.warming
393
+ }
394
+
260
395
  const HOP_BY_HOP = new Set([
261
396
  'connection',
262
397
  'keep-alive',
@@ -347,6 +482,76 @@ function upstreamPath(rawUrl) {
347
482
  return `${pathname}${url.search}`
348
483
  }
349
484
 
485
+ /** 只有静态资源进缓存:带内容哈希,路径一变就是新文件,不存在脏读。 */
486
+ function cacheableKey(req) {
487
+ if (req.method !== 'GET' && req.method !== 'HEAD') return ''
488
+ let pathname = '/'
489
+ try {
490
+ pathname = new URL(req.url || '/', 'http://placeholder.invalid').pathname || '/'
491
+ } catch {
492
+ return ''
493
+ }
494
+ if (pathname === '/') return ENTRY_PATH
495
+ if (pathname.startsWith('/assets/')) return pathname
496
+ if (pathname.startsWith('/plugins/')) return pathname
497
+ return ''
498
+ }
499
+
500
+ function cacheGet(state, key) {
501
+ const hit = state.cache.get(key)
502
+ if (!hit) return null
503
+ if (key === ENTRY_PATH && Date.now() - hit.at > ENTRY_TTL_MS) {
504
+ state.cache.delete(key)
505
+ state.cacheBytes -= hit.body.length
506
+ return null
507
+ }
508
+ // LRU:命中就挪到末尾
509
+ state.cache.delete(key)
510
+ state.cache.set(key, hit)
511
+ return hit
512
+ }
513
+
514
+ function cacheSet(state, key, type, body) {
515
+ if (!body || body.length === 0 || body.length > MAX_CACHE_ENTRY) return
516
+ const old = state.cache.get(key)
517
+ if (old) {
518
+ state.cache.delete(key)
519
+ state.cacheBytes -= old.body.length
520
+ }
521
+ state.cache.set(key, { type: type || 'application/octet-stream', body, at: Date.now() })
522
+ state.cacheBytes += body.length
523
+ while (state.cacheBytes > MAX_CACHE_BYTES && state.cache.size > 1) {
524
+ const [oldestKey, oldest] = state.cache.entries().next().value
525
+ state.cache.delete(oldestKey)
526
+ state.cacheBytes -= oldest.body.length
527
+ }
528
+ }
529
+
530
+ /**
531
+ * 手机上的排版修补:直接注入 iframe 里那份文档。
532
+ * iframe 与外层不同源(端口不同),外层改不了它的 DOM,只能在代理这一层注入;
533
+ * 全部规则都在 `(max-width: 1023px) and (pointer: coarse)` 里,桌面上完全无副作用。
534
+ */
535
+ const MOBILE_CSS = `@media (max-width: 1023px) and (pointer: coarse) {
536
+ html { -webkit-text-size-adjust: 100%; }
537
+ body { overflow-x: hidden; }
538
+ button, [role="button"], [role="tab"], [role="menuitem"], [role="option"] { min-height: 40px; min-width: 40px; }
539
+ input, select, textarea { min-height: 40px; font-size: 16px; }
540
+ [class*="drawer"], aside { max-width: 88vw; }
541
+ table { display: block; overflow-x: auto; }
542
+ textarea { padding-bottom: max(10px, env(safe-area-inset-bottom)); }
543
+ }`
544
+
545
+ /** 给 iframe 里的文档报进度用;只 postMessage,不碰 DOM。 */
546
+ const PROGRESS_SCRIPT = `<script id="dsh-embed-progress">(function(){var send=function(t){try{parent.postMessage({source:'dsh-embed',type:t},'*')}catch(e){}};send('dom');addEventListener('load',function(){send('load')});var n=0,t=setInterval(function(){if(window.__DSH_BOOT_READY__){send('boot');clearInterval(t)}if(++n>480)clearInterval(t)},250)})()<\/script>`
547
+
548
+ function injectIntoHtml(html) {
549
+ const style = `<style id="dsh-embed-mobile">${MOBILE_CSS}</style>`
550
+ let out = html.includes('</head>') ? html.replace('</head>', `${style}</head>`) : style + html
551
+ out = out.includes('</body>') ? out.replace('</body>', `${PROGRESS_SCRIPT}</body>`) : out + PROGRESS_SCRIPT
552
+ return out
553
+ }
554
+
350
555
  /**
351
556
  * 通用反向代理:路径、方法、请求体、响应体一律原样转发,cookie 服务端注入。
352
557
  * 上游 401 时重铸一次再重试(设备会话过期 / cookie 被顶掉的常见情形)。
@@ -363,16 +568,60 @@ function proxyOnce(state, clientReq, clientRes, retried = false) {
363
568
  if (target.origin !== state.relay) {
364
569
  return sendHtml(clientRes, 400, page('地址不对', '<p>只代理同一个中继。</p>'))
365
570
  }
571
+ const cacheKey = cacheableKey(clientReq)
572
+ if (cacheKey) {
573
+ const hit = cacheGet(state, cacheKey)
574
+ if (hit) {
575
+ // 入口页命中也要补注入(缓存里存的是原始 HTML),
576
+ // 注入后长度会变,content-length 必须按最终 body 算,否则响应会被截断。
577
+ const body = cacheKey === ENTRY_PATH ? Buffer.from(injectIntoHtml(hit.body.toString('utf8'))) : hit.body
578
+ clientRes.writeHead(200, {
579
+ 'content-type': hit.type,
580
+ 'content-length': String(body.length),
581
+ 'cache-control': cacheKey === ENTRY_PATH ? 'no-cache' : 'public, max-age=31536000, immutable',
582
+ 'x-dsh-embed-cache': 'hit',
583
+ })
584
+ clientRes.end(body)
585
+ return
586
+ }
587
+ // 预热和真实请求撞在一起时只发一次上游请求
588
+ const pending = state.inflight.get(cacheKey)
589
+ if (pending) {
590
+ pending
591
+ .catch(() => {})
592
+ .then(() => proxyOnce(state, clientReq, clientRes, retried))
593
+ return
594
+ }
595
+ }
596
+
366
597
  const mod = target.protocol === 'https:' ? httpsRequest : httpRequest
598
+ const wanted = upstreamPath(clientReq.url)
367
599
  const upstream = mod({
368
600
  protocol: target.protocol,
369
601
  hostname: target.hostname,
370
602
  port: target.port || undefined,
371
603
  method: clientReq.method,
372
- path: upstreamPath(clientReq.url),
604
+ path: wanted,
373
605
  headers: upstreamHeaders(clientReq.headers, state.cookie, target.host),
374
- agent: false,
606
+ agent: agentFor(target),
375
607
  })
608
+ if (cacheKey) {
609
+ let settle
610
+ const done = new Promise((r) => {
611
+ settle = r
612
+ })
613
+ state.inflight.set(cacheKey, done)
614
+ const clear = () => {
615
+ if (state.inflight.get(cacheKey) === done) state.inflight.delete(cacheKey)
616
+ settle()
617
+ }
618
+ upstream.on('close', clear)
619
+ upstream.on('error', clear)
620
+ upstream.on('response', (up) => {
621
+ up.on('end', clear)
622
+ up.on('error', clear)
623
+ })
624
+ }
376
625
  upstream.on('response', (up) => {
377
626
  if (up.statusCode === 401 && !retried && state.canRemint) {
378
627
  up.resume()
@@ -381,10 +630,71 @@ function proxyOnce(state, clientReq, clientRes, retried = false) {
381
630
  .then(() => proxyOnce(state, clientReq, clientRes, true))
382
631
  .catch((error) => proxyOnce(state, clientReq, clientRes, true) ?? sendHtml(clientRes, 502, page('上游失败', `<p>${escapeHtml(error?.message)}</p>`)))
383
632
  }
633
+ const type = String(up.headers['content-type'] || '')
634
+ const status = up.statusCode ?? 502
635
+
636
+ // 入口页:缓冲下来做两件事——写进缓存、注入手机排版与进度脚本
637
+ if (status === 200 && type.includes('text/html') && (cacheKey === ENTRY_PATH || wanted === ENTRY_PATH)) {
638
+ const chunks = []
639
+ up.on('data', (chunk) => chunks.push(chunk))
640
+ up.on('end', () => {
641
+ const raw = Buffer.concat(chunks)
642
+ cacheSet(state, ENTRY_PATH, 'text/html; charset=utf-8', raw)
643
+ const body = Buffer.from(injectIntoHtml(raw.toString('utf8')))
644
+ const headers = downstreamHeaders(up.headers, state.relay)
645
+ headers['content-type'] = 'text/html; charset=utf-8'
646
+ headers['content-length'] = String(body.length)
647
+ headers['cache-control'] = 'no-cache'
648
+ headers['x-dsh-embed-injected'] = 'mobile'
649
+ clientRes.writeHead(200, headers)
650
+ clientRes.end(body)
651
+ })
652
+ up.on('error', () => {
653
+ if (!clientRes.headersSent) sendHtml(clientRes, 502, page('上游失败', '<p>入口页读取中断。</p>'))
654
+ else clientRes.destroy()
655
+ })
656
+ return
657
+ }
658
+
659
+ // 静态资源:边转发边存,超过上限就放弃缓存但照常转发
660
+ if (status === 200 && cacheKey && !type.includes('text/html')) {
661
+ const chunks = []
662
+ let size = 0
663
+ let streaming = false
664
+ up.on('data', (chunk) => {
665
+ if (streaming) return
666
+ chunks.push(chunk)
667
+ size += chunk.length
668
+ if (size > MAX_CACHE_ENTRY) {
669
+ streaming = true
670
+ const headers = downstreamHeaders(up.headers, state.relay)
671
+ clientRes.writeHead(status, headers)
672
+ clientRes.write(Buffer.concat(chunks))
673
+ chunks.length = 0
674
+ }
675
+ })
676
+ up.on('end', () => {
677
+ if (streaming) {
678
+ clientRes.end()
679
+ return
680
+ }
681
+ const body = Buffer.concat(chunks)
682
+ cacheSet(state, cacheKey, type || 'application/octet-stream', body)
683
+ const headers = downstreamHeaders(up.headers, state.relay)
684
+ headers['content-length'] = String(body.length)
685
+ headers['cache-control'] = 'public, max-age=31536000, immutable'
686
+ headers['x-dsh-embed-cache'] = 'miss'
687
+ clientRes.writeHead(status, headers)
688
+ clientRes.end(body)
689
+ })
690
+ up.on('error', () => clientRes.destroy())
691
+ return
692
+ }
693
+
384
694
  const headers = downstreamHeaders(up.headers, state.relay)
385
695
  const location = rewriteLocation(up.headers.location, state.relay)
386
696
  if (location) headers.location = location
387
- clientRes.writeHead(up.statusCode ?? 502, headers)
697
+ clientRes.writeHead(status, headers)
388
698
  up.pipe(clientRes)
389
699
  })
390
700
  upstream.on('error', (error) => {
@@ -417,7 +727,7 @@ function proxyUpgrade(state, req, socket, head) {
417
727
  method: 'GET',
418
728
  path: upstreamPath(req.url),
419
729
  headers,
420
- agent: false,
730
+ agent: agentFor(target),
421
731
  })
422
732
  upstream.on('upgrade', (up, upSocket, upHead) => {
423
733
  const lines = [`HTTP/1.1 101 ${up.statusMessage || 'Switching Protocols'}`]
@@ -477,20 +787,73 @@ function page(title, body, extraHead = '') {
477
787
  </style>${extraHead}</head><body><main>${body}</main></body></html>`
478
788
  }
479
789
 
480
- /** iframe 外壳:整页只有一个 iframe,唯一的外壳元素是一个小齿轮。 */
790
+ /** iframe 外壳:iframe + 一层加载进度(纯 CSS 动画,界面一就绪就淡出)。 */
481
791
  function embedPage(state, port) {
482
792
  const src = `http://127.0.0.1:${port}/`
793
+ const origin = `http://127.0.0.1:${port}`
794
+ const warm = state.warm?.ok ? `<span id="dsh-embed-warm">已预热 ${state.warm.fetched}/${state.warm.assets}</span>` : ''
795
+ const overlay = `<div id="dsh-embed-cover">
796
+ <div class="ring"></div>
797
+ <b>正在打开桌面版</b>
798
+ <p id="dsh-embed-step">正在建立配对…</p>
799
+ <span id="dsh-embed-time">0s</span>
800
+ ${warm}
801
+ <a href="/embed/setup">设置</a>
802
+ </div>`
803
+ const script = `<script>(function(){
804
+ var cover=document.getElementById('dsh-embed-cover')
805
+ var step=document.getElementById('dsh-embed-step')
806
+ var clock=document.getElementById('dsh-embed-time')
807
+ var frame=document.getElementById('dsh-embed-frame')
808
+ var t0=Date.now(), hints=[[0,'正在建立配对…'],[6,'正在下载界面资源(第一次最慢)…'],[20,'正在启动界面插件…'],[45,'中继这条路有点慢,还在下载…']]
809
+ var timer=setInterval(function(){
810
+ var s=Math.round((Date.now()-t0)/1000)
811
+ clock.textContent=s+'s'
812
+ var pick=0
813
+ for(var i=0;i<hints.length;i++){ if(s>=hints[i][0]) pick=i }
814
+ if(step.textContent!==hints[pick][1]) step.textContent=hints[pick][1]
815
+ },500)
816
+ function done(){
817
+ if(!cover||cover.dataset.done) return
818
+ cover.dataset.done='1'
819
+ window.__DSH_EMBED_READY_MS__=Math.round(performance.now())
820
+ clearInterval(timer)
821
+ cover.style.opacity='0'; cover.style.pointerEvents='none'
822
+ setTimeout(function(){ if(cover&&cover.parentNode) cover.parentNode.removeChild(cover) },500)
823
+ }
824
+ window.addEventListener('message',function(e){
825
+ if(e.origin!=='${origin}') return
826
+ var d=e.data
827
+ if(d&&d.source==='dsh-embed'&&(d.type==='load'||d.type==='boot')) done()
828
+ })
829
+ frame.addEventListener('load',function(){ setTimeout(done,600) })
830
+ setTimeout(function(){ if(cover&&!cover.dataset.done) step.textContent='还没回来:确认电脑上 bridge.py 在跑,或回设置页重填地址。' },180000)
831
+ })()<\/script>`
483
832
  return page(
484
833
  '桌面版 DSH',
485
- `<iframe src="${escapeHtml(src)}" title="桌面版 DSH"
834
+ `<iframe id="dsh-embed-frame" src="${escapeHtml(src)}" title="桌面版 DSH"
486
835
  allow="clipboard-read; clipboard-write; microphone; camera"
487
836
  style="position:fixed;inset:0;width:100%;height:100%;border:0;background:#fff"></iframe>
837
+ ${overlay}
488
838
  <a href="/embed/setup" title="中转与端口设置"
489
839
  style="position:fixed;right:10px;bottom:calc(10px + env(safe-area-inset-bottom));z-index:2147483000;
490
840
  width:40px;height:40px;border-radius:20px;background:rgba(0,0,0,.45);color:#fff;
491
841
  display:flex;align-items:center;justify-content:center;text-decoration:none;
492
- font:18px/1 system-ui">⚙</a>`,
493
- '<style>body{margin:0;padding:0;overflow:hidden}</style>',
842
+ font:18px/1 system-ui">⚙</a>
843
+ ${script}`,
844
+ `<style>
845
+ body{margin:0;padding:0;overflow:hidden}
846
+ #dsh-embed-cover{position:fixed;inset:0;z-index:2147483600;background:#0f1115;color:#e8e8ea;
847
+ display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;
848
+ font:15px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;transition:opacity .4s}
849
+ #dsh-embed-cover b{font-size:17px;font-weight:600}
850
+ #dsh-embed-cover p{margin:0;color:#9aa0a6;font-size:14px;max-width:80vw;text-align:center}
851
+ #dsh-embed-cover span{color:#6b7280;font-size:13px}
852
+ #dsh-embed-cover a{color:#7aa2ff;font-size:14px;text-decoration:none;margin-top:4px}
853
+ .ring{width:38px;height:38px;border-radius:50%;border:3px solid rgba(255,255,255,.15);
854
+ border-top-color:#7aa2ff;animation:dsh-embed-spin 1s linear infinite}
855
+ @keyframes dsh-embed-spin{to{transform:rotate(360deg)}}
856
+ </style>`,
494
857
  )
495
858
  }
496
859
 
@@ -553,6 +916,10 @@ async function apply(ctx, rawConfig) {
553
916
  state.remint = () => ensureSession(state, cfgOf())
554
917
  state.canRemint = true
555
918
 
919
+ // 配置里已经有中转地址就先把配对拿好、把资源预热好。
920
+ // 既修掉「重启 DSHA 后不重填设置就打不开」,也让用户点按钮时资源已经在内存里。
921
+ if (cfgOf().bridgeUrl) void prewarm(state, cfgOf())
922
+
556
923
  // ---- 回环代理 -----------------------------------------------------------
557
924
  const proxy = createServer((req, res) => {
558
925
  if (!gateOk(state, req)) {
@@ -631,6 +998,7 @@ async function apply(ctx, rawConfig) {
631
998
  } catch (error) {
632
999
  return sendHtml(res, 502, setupPage(state, next, `配对失败:${error?.message ?? error}`))
633
1000
  }
1001
+ void prewarm(state, next)
634
1002
  res.writeHead(303, { location: EMBED_PATH, 'cache-control': 'no-store' })
635
1003
  res.end()
636
1004
  },
@@ -655,6 +1023,12 @@ async function apply(ctx, rawConfig) {
655
1023
  bridgeUrl: live.bridgeUrl,
656
1024
  configPath: configPath(),
657
1025
  embedPath: EMBED_PATH,
1026
+ cache: {
1027
+ entries: state.cache.size,
1028
+ bytes: state.cacheBytes,
1029
+ budget: MAX_CACHE_BYTES,
1030
+ },
1031
+ warm: state.warm,
658
1032
  })
659
1033
  },
660
1034
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-desktop-embed",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Embed the desktop DSH UI inside DSHA's WebView instead of handing it to the system browser: a loopback reverse proxy on the phone plus an iframe shell page.",
5
5
  "type": "module",
6
6
  "license": "MIT",