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.
- package/README.md +38 -0
- package/index.js +385 -11
- 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:
|
|
604
|
+
path: wanted,
|
|
373
605
|
headers: upstreamHeaders(clientReq.headers, state.cookie, target.host),
|
|
374
|
-
agent:
|
|
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(
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|