dsh-mobile-plugin 0.5.10 → 0.5.11
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/lib/cli/pair.cjs +104 -0
- package/lib/client.js +68 -3
- package/lib/impl.js +9 -40
- package/lib/mobile/extension-dev.js +189 -0
- package/lib/mobile/history-derive.js +18 -0
- package/lib/mobile/model-caps.js +239 -0
- package/lib/mobile/model-meta-table.cjs +21 -0
- package/lib/mobile/model-meta.js +133 -0
- package/lib/mobile/monitor.js +21 -0
- package/lib/mobile/push-notify.js +5 -4
- package/lib/mobile/relay-control.js +17 -4
- package/lib/mobile/session-head.js +78 -0
- package/lib/mobile/sessions.js +4 -31
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +3 -0
- package/lib/relay/firewall-probe.js +40 -8
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/relay/pairing-payload.cjs +67 -0
- package/lib/routes/control-plane.js +30 -19
- package/lib/routes/mobile-api.js +19 -3
- package/lib/update-install.cjs +70 -9
- package/package.json +3 -2
package/lib/cli/pair.cjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 在终端里把配对二维码打出来(`dsh-mobile-plugin-pair`)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么需要它:二维码那条路由只在本机屏幕或者两小时窗口里给码,而**服务器部署**上
|
|
8
|
+
* 「本机屏幕」这个概念是空的 —— 那台机器的设置页往往开不了,人也够不到它的回环地址。
|
|
9
|
+
* 能在服务器终端里敲命令的人本来就有这台机器的权限,把码打给他,信任量级与「在本机屏幕
|
|
10
|
+
* 上看码」相同,也不新增任何对外的口子。
|
|
11
|
+
*
|
|
12
|
+
* 载荷与面板同一份(`../relay/pairing-payload.cjs`),所以打出来的码一定和面板里那张能
|
|
13
|
+
* 配上同一台机器;这里只负责画图与说人话,不碰配对流程。
|
|
14
|
+
*
|
|
15
|
+
* 用法:
|
|
16
|
+
* dsh-mobile-plugin-pair 在终端里打出二维码
|
|
17
|
+
* dsh-mobile-plugin-pair --svg qr.svg 另外存一份 SVG(终端太窄、想发给别人时用)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const QRCode = require('qrcode');
|
|
23
|
+
const core = require('../relay/core.js');
|
|
24
|
+
const { pairingPayload } = require('../relay/pairing-payload.cjs');
|
|
25
|
+
|
|
26
|
+
const USAGE = `用法:
|
|
27
|
+
dsh-mobile-plugin-pair 在终端里打印配对二维码
|
|
28
|
+
dsh-mobile-plugin-pair --svg <路径> 同时把二维码存成 SVG 图片`;
|
|
29
|
+
|
|
30
|
+
function fail(message) {
|
|
31
|
+
process.stderr.write(`${message}\n`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const out = { svg: '' };
|
|
37
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
38
|
+
const arg = argv[index];
|
|
39
|
+
if (arg === '--svg') {
|
|
40
|
+
const next = argv[index + 1];
|
|
41
|
+
if (!next || next.startsWith('-')) return { error: '--svg 后面要跟一个文件路径' };
|
|
42
|
+
out.svg = next;
|
|
43
|
+
index += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (arg === '--help' || arg === '-h') return { help: true };
|
|
47
|
+
return { error: `不认识的参数:${arg}` };
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function main(argv) {
|
|
53
|
+
const args = parseArgs(argv);
|
|
54
|
+
if (args.help) {
|
|
55
|
+
process.stdout.write(`${USAGE}\n`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (args.error) {
|
|
59
|
+
fail(`${args.error}\n\n${USAGE}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 状态文件是宿主与这里唯一的交接口:宿主每次换码换票据都会落盘,所以读到的那份就是
|
|
64
|
+
// 跑着的宿主手上那份。文件不存在时**不替它建**(`loadState()` 会建),直接说清楚。
|
|
65
|
+
if (!fs.existsSync(core.STATE_FILE)) {
|
|
66
|
+
fail(`这台机器上还没跑过这个插件(找不到 ${core.STATE_FILE})。先启动 DSH,并在移动端插件里启用远程控制。`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const state = core.loadState();
|
|
70
|
+
if (!state.enabled) {
|
|
71
|
+
fail('远程控制还没启用:先在 DSH 的移动端设置页(或面板)里打开远程控制并保存配置。');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const { payload, text } = pairingPayload(state, state.lanPort);
|
|
75
|
+
if (!payload.relayUrl && payload.lanUrls.length === 0) {
|
|
76
|
+
fail('没有可用的中转或直连地址:先在面板上配好中转地址,或打开局域网直连,再回来打码。');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lines = [];
|
|
81
|
+
lines.push(`宿主:${payload.name}`);
|
|
82
|
+
lines.push(`配对码:${payload.code}`);
|
|
83
|
+
lines.push(`中转地址:${payload.relayUrl || '(没配)'}`);
|
|
84
|
+
if (payload.lanUrls.length > 0) {
|
|
85
|
+
lines.push(`直连地址:${payload.lanUrls.join(' ')}`);
|
|
86
|
+
lines.push(`局域网端口:${state.lanPort}`);
|
|
87
|
+
}
|
|
88
|
+
process.stdout.write(`${lines.join('\n')}\n\n`);
|
|
89
|
+
|
|
90
|
+
if (args.svg) {
|
|
91
|
+
const target = path.resolve(args.svg);
|
|
92
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
93
|
+
fs.writeFileSync(target, await QRCode.toString(text, { type: 'svg', errorCorrectionLevel: 'M', margin: 2, width: 480 }));
|
|
94
|
+
process.stdout.write(`二维码已存成图片:${target}\n如果终端太窄扫不动,把这个文件拿下来用看图软件打开再扫。\n\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// small: true 让一个字符带两行模块,绝大多数终端一行装得下;扫不动就加 --svg。
|
|
98
|
+
process.stdout.write(`${await QRCode.toString(text, { type: 'terminal', small: true, errorCorrectionLevel: 'M' })}\n`);
|
|
99
|
+
process.stdout.write('用 DSH App 扫这张码即可配对。它等于一次配对权限,别截图发给别人;配完可以在面板上点「重新生成配对码」。\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
103
|
+
fail(`打码失败:${error && error.message ? error.message : String(error)}`);
|
|
104
|
+
});
|
package/lib/client.js
CHANGED
|
@@ -482,6 +482,16 @@ window.__ModuleLoader__.load({
|
|
|
482
482
|
hostHalfRetrying: '它还在后台自己重试,过几分钟刷新这一页就能看到结果;也可以点下面的按钮立刻再试一次。',
|
|
483
483
|
hostHalfReload: '重新装载',
|
|
484
484
|
hostHalfReloadFailed: '重新装载没成:',
|
|
485
|
+
hostHalfUnreachable: '宿主侧的自检口也没有回应:这一版插件可能还没带它(较老的版本)——先更新插件,再完整重启一次 DSH。',
|
|
486
|
+
hostHalfNoError: '宿主侧自检没报错,控制面路由却确实不在:把 DSH 完整重启一次;还不行请把这句连同插件版本发给我们。',
|
|
487
|
+
hostHalfVersion: '自检版本:',
|
|
488
|
+
diagHostReturned: '宿主返回:',
|
|
489
|
+
diagSelfCheck: '自检:',
|
|
490
|
+
diagSelfCheckNoAnswer: '没有回应(这一版可能还没带它)',
|
|
491
|
+
diagSelfCheckNoError: '没有报错',
|
|
492
|
+
diagSelfCheckRetry: '后台重试 ',
|
|
493
|
+
diagOnDisk: '碟上版本 ',
|
|
494
|
+
diagShellRestartAtStartup: '外壳改过,要重启一次 DSH',
|
|
485
495
|
webAccessOn: '公网网页管理已开放,到期时间',
|
|
486
496
|
webAccessOff: '公网网页管理已关闭',
|
|
487
497
|
webAccessOpen: '开放网页管理 2 小时',
|
|
@@ -504,6 +514,16 @@ window.__ModuleLoader__.load({
|
|
|
504
514
|
hostHalfRetrying: 'It is still retrying on its own in the background — reopen this item in a few minutes, or press the button below to retry right now.',
|
|
505
515
|
hostHalfReload: 'Reload now',
|
|
506
516
|
hostHalfReloadFailed: 'Reload failed: ',
|
|
517
|
+
hostHalfUnreachable: 'The host-side self-check did not answer either: this version of the plugin may not ship it yet (older build) — update the plugin, then restart DSH completely.',
|
|
518
|
+
hostHalfNoError: 'The host self-check reports no error, yet the control-plane routes really are missing: restart DSH completely; if that does not help, send us this line together with the plugin version.',
|
|
519
|
+
hostHalfVersion: 'self-check version: ',
|
|
520
|
+
diagHostReturned: 'Host returned: ',
|
|
521
|
+
diagSelfCheck: 'Self-check: ',
|
|
522
|
+
diagSelfCheckNoAnswer: 'no answer (this build may not ship it)',
|
|
523
|
+
diagSelfCheckNoError: 'no error reported',
|
|
524
|
+
diagSelfCheckRetry: 'background retries: ',
|
|
525
|
+
diagOnDisk: 'on disk: ',
|
|
526
|
+
diagShellRestartAtStartup: 'shell changed — DSH needs one restart',
|
|
507
527
|
webAccessOn: 'Public web management is open until',
|
|
508
528
|
webAccessOff: 'Public web management is closed',
|
|
509
529
|
webAccessOpen: 'Open web management for 2 hours',
|
|
@@ -573,15 +593,17 @@ window.__ModuleLoader__.load({
|
|
|
573
593
|
refreshTimer = null;
|
|
574
594
|
}
|
|
575
595
|
// 404 = 实现那一半没装载。它为什么没装载,只有外壳答得出来(这两条路由挂在
|
|
576
|
-
//
|
|
596
|
+
// 外壳上,实现挂了它们还在);老版本宿主没有这条口,问不到也得把这个事实留住
|
|
597
|
+
// (`unavailable`)——「自检口都没有回应」和「自检说没事」是两种不同的现场,
|
|
598
|
+
// 都缩成一句「重启/重装」的话,用户只能对着空白猜(2026-11-01 现场)。
|
|
577
599
|
if (problem?.status === 404) {
|
|
578
600
|
try {
|
|
579
601
|
const picture = await call(HOST_HALF_ENDPOINT);
|
|
580
602
|
if (!alive) return;
|
|
581
|
-
setHalf(picture && typeof picture.half === 'object' ? picture.half :
|
|
603
|
+
setHalf(picture && typeof picture.half === 'object' ? picture.half : { unavailable: true });
|
|
582
604
|
} catch {
|
|
583
605
|
if (!alive) return;
|
|
584
|
-
setHalf(
|
|
606
|
+
setHalf({ unavailable: true });
|
|
585
607
|
}
|
|
586
608
|
} else {
|
|
587
609
|
setHalf(null);
|
|
@@ -648,16 +670,59 @@ window.__ModuleLoader__.load({
|
|
|
648
670
|
|
|
649
671
|
const missing = errorStatus === 404;
|
|
650
672
|
|
|
673
|
+
/**
|
|
674
|
+
* 诊断读数:宿主回了什么 + 外壳那份自检说了什么。
|
|
675
|
+
*
|
|
676
|
+
* 只拼进这一屏、不落盘也不上报:它的用途是让人截图发给我们(2026-11-01 用户原话:
|
|
677
|
+
* 「以后用户发过来 我也不好去排查」)。字段名用 `version=` / `source=` 这种记号而不是
|
|
678
|
+
* 翻成中文,是因为它要能贴进日志里对得上宿主那一侧。
|
|
679
|
+
*/
|
|
680
|
+
const diagLines = [];
|
|
681
|
+
if (missing) {
|
|
682
|
+
diagLines.push(`${t('diagHostReturned')}${error}`);
|
|
683
|
+
if (half && half.unavailable === true) {
|
|
684
|
+
diagLines.push(`${t('diagSelfCheck')}${t('diagSelfCheckNoAnswer')}`);
|
|
685
|
+
} else if (half) {
|
|
686
|
+
const bits = [];
|
|
687
|
+
if (half.version) bits.push(`version=${half.version}`);
|
|
688
|
+
if (half.source) bits.push(`source=${half.source}`);
|
|
689
|
+
if (half.activatedAt) bits.push(`at=${half.activatedAt}`);
|
|
690
|
+
if (half.retrying) {
|
|
691
|
+
const attempts = Number.isFinite(half.retryAttempts) ? half.retryAttempts : 0;
|
|
692
|
+
bits.push(`${t('diagSelfCheckRetry')}${attempts}${half.nextRetryAt ? `→${half.nextRetryAt}` : ''}`);
|
|
693
|
+
}
|
|
694
|
+
if (half.installedVersion && half.installedVersion !== half.version) {
|
|
695
|
+
bits.push(`${t('diagOnDisk')}${half.installedVersion}`);
|
|
696
|
+
}
|
|
697
|
+
if (half.shellChanged) bits.push(t('diagShellRestartAtStartup'));
|
|
698
|
+
diagLines.push(`${t('diagSelfCheck')}${bits.length ? bits.join(' · ') : t('diagSelfCheckNoError')}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
651
702
|
return h('div', { className: 'dshm_root' }, [
|
|
652
703
|
h('h2', { className: 'dshm_heading' }, t('heading')),
|
|
653
704
|
error
|
|
654
705
|
? h('p', { className: 'dshm_error' }, missing ? t('loadMissing') : `${t('loadFailed')}: ${error}`)
|
|
655
706
|
: null,
|
|
707
|
+
// 404 那屏必须自带「能拿去排查」的读数(2026-11-01 用户要求):只写一句「宿主侧没起来」
|
|
708
|
+
// 的话,用户截图发过来我们还是不知道坏在哪一层,只能让他一次次试。
|
|
709
|
+
// 两行足够定位:宿主到底回了什么、外壳那份自检说了什么。
|
|
710
|
+
...diagLines.map((line) => h('p', { className: 'dshm_note', key: line.slice(0, 24) }, line)),
|
|
656
711
|
// 404 时把宿主那半边自己的说法贴出来:重启 / 重装这两句提示在「装载超时」那种
|
|
657
712
|
// 现场是帮倒忙的,真正该做的是等它后台补齐、或者点一下重新装载。
|
|
658
713
|
missing && half && typeof half.lastError === 'string' && half.lastError
|
|
659
714
|
? h('p', { className: 'dshm_note' }, `${t('hostHalfReason')}${half.lastError}`)
|
|
660
715
|
: null,
|
|
716
|
+
// 问不到自检口:说明这一版插件压根没带它(比 0.5.11 老),用户这时最该做的是更新
|
|
717
|
+
// 插件,而不是继续重启 —— 原先这一支是一片空白,只留一句「重启/重装」。
|
|
718
|
+
missing && half && half.unavailable === true
|
|
719
|
+
? h('p', { className: 'dshm_note' }, t('hostHalfUnreachable'))
|
|
720
|
+
: null,
|
|
721
|
+
// 自检说没事、路由却不在:这是「同一份 DSH 里跑着两份插件」那种现场,报出版本
|
|
722
|
+
// 才查得下去。
|
|
723
|
+
missing && half && half.unavailable !== true && !(typeof half.lastError === 'string' && half.lastError)
|
|
724
|
+
? h('p', { className: 'dshm_note' }, `${t('hostHalfNoError')}${typeof half.version === 'string' && half.version ? `(${t('hostHalfVersion')}${half.version})` : ''}`)
|
|
725
|
+
: null,
|
|
661
726
|
missing && half && half.retrying === true
|
|
662
727
|
? h('p', { className: 'dshm_note' }, t('hostHalfRetrying'))
|
|
663
728
|
: null,
|
package/lib/impl.js
CHANGED
|
@@ -28,7 +28,9 @@ const { deviceMetaFromHeaders } = require('./shared/device-headers.js')
|
|
|
28
28
|
const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
|
|
29
29
|
const { createMirrorPusher } = require('./mirror/pusher.cjs')
|
|
30
30
|
const { randomBytes } = require('node:crypto')
|
|
31
|
-
const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates } = require('./relay/lan-network.cjs')
|
|
31
|
+
const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManualIpv6Candidate } = require('./relay/lan-network.cjs')
|
|
32
|
+
// 配对二维码的载荷:面板与终端命令共用一份,拼装在 `./relay/pairing-payload.cjs`。
|
|
33
|
+
const { pairingPayload } = require('./relay/pairing-payload.cjs')
|
|
32
34
|
const { resolvePort } = require('./relay/port-allocator.cjs')
|
|
33
35
|
const { createPreviewService } = require('./relay/preview.js')
|
|
34
36
|
// 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
|
|
@@ -93,6 +95,9 @@ export const RemoteControlSettingsSchema = z.object({
|
|
|
93
95
|
preferredLanAddress: z.string().max(64).default(''),
|
|
94
96
|
// 用户手填的直连地址(`manualLanUrl`,内网穿透域名)也随二维码一起走,见 lan-network.cjs。
|
|
95
97
|
manualLanUrl: z.string().max(200).default(''),
|
|
98
|
+
// 用户手填的 IPv6 直连地址(`manualIpv6Url`,形如 `http://[240e::1]:18787`):与上一条并列,
|
|
99
|
+
// 只是拨的是 IPv6;网卡扫不到的那条固定地址靠它兜底,候选口径见 lan-network.cjs。
|
|
100
|
+
manualIpv6Url: z.string().max(200).default(''),
|
|
96
101
|
})
|
|
97
102
|
|
|
98
103
|
|
|
@@ -111,6 +116,7 @@ function configOf(state) {
|
|
|
111
116
|
registerSecret: typeof state.registerSecret === 'string' ? state.registerSecret : '',
|
|
112
117
|
preferredLanAddress: typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '',
|
|
113
118
|
manualLanUrl: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
|
|
119
|
+
manualIpv6Url: typeof state.manualIpv6Url === 'string' ? state.manualIpv6Url : '',
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
|
|
@@ -166,44 +172,6 @@ function createConfigStore(state) {
|
|
|
166
172
|
}
|
|
167
173
|
}
|
|
168
174
|
|
|
169
|
-
function pairingPayload(state, lanPort) {
|
|
170
|
-
const port = Number.isInteger(lanPort) ? lanPort : state.lanPort
|
|
171
|
-
// 面板上选过的「手机优先用这条」在这里兑现:只挪到最前,其余继续当兜底(App 按顺序挨个试)。
|
|
172
|
-
const candidates = state.lanEnabled
|
|
173
|
-
? phoneCandidates(advertisedCandidates(port), { preferred: state.preferredLanAddress, manual: state.manualLanUrl })
|
|
174
|
-
: []
|
|
175
|
-
const urls = candidates.map(candidate => candidate.url).filter(Boolean)
|
|
176
|
-
const relayUrl = state.relayUrl || ''
|
|
177
|
-
const payload = {
|
|
178
|
-
type: 'dsh-pair',
|
|
179
|
-
version: 1,
|
|
180
|
-
name: state.name || os.hostname(),
|
|
181
|
-
instanceId: state.instanceId,
|
|
182
|
-
code: state.pairingCode,
|
|
183
|
-
// 协议版本与一次性票据(§8.3(1))。
|
|
184
|
-
//
|
|
185
|
-
// `proto: 2` 由**宿主**写、也由宿主生成,中转没有任何机会改写它 —— 所以「新 App 被
|
|
186
|
-
// 中转骗回明文」这个问题从一开始就不存在。`ticket` 是 32 字节随机数,手机拿它派生
|
|
187
|
-
// 配对密钥;二维码是屏幕对摄像头,这条通道完全绕开中转。
|
|
188
|
-
//
|
|
189
|
-
// 老 App 只取自己认识的那几个字段,多出来的两个会被忽略 —— 一张二维码同时服务新旧两种。
|
|
190
|
-
proto: 2,
|
|
191
|
-
ticket: state.pairingTicket || '',
|
|
192
|
-
relayUrl,
|
|
193
|
-
lanUrls: urls,
|
|
194
|
-
// 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
|
|
195
|
-
// 依次重试而不用先回到公网中转去问。
|
|
196
|
-
lanCandidates: candidates.map(candidate => candidate.url).filter(Boolean),
|
|
197
|
-
// IPv6 直连候选(理想情况下是手机**不在同一个 Wi-Fi 里**时唯一能拨到的地址):不参与上面
|
|
198
|
-
// 那份的排序,顺序由 App 侧拿它和公网中转实测比出来;直连监听没开就没有这条通道。
|
|
199
|
-
ipv6Candidates: state.lanEnabled ? ipv6Candidates(port).map(candidate => candidate.url).filter(Boolean) : [],
|
|
200
|
-
}
|
|
201
|
-
return {
|
|
202
|
-
payload,
|
|
203
|
-
text: `dshpair://v1?data=${encodeURIComponent(JSON.stringify(payload))}`,
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
175
|
// 上报给 App 的候选地址(私网优先、没有私网才退公网)住在 `./relay/lan-network.cjs`
|
|
208
176
|
// 的 `advertisedCandidates`:那是地址策略,不是装配。这里只把它转交给三处消费点。
|
|
209
177
|
// 「手机上最近来过没有」同理,住在 `./mobile/activity.cjs`(守卫记、控制面状态读)。
|
|
@@ -490,7 +458,8 @@ function apply(ctx) {
|
|
|
490
458
|
// 移动端那边的「局域网候选」:私网优先的清单 + 用户手填的直连地址(网卡扫不出的域名)。
|
|
491
459
|
safeLanCandidates: (port) => phoneCandidates(advertisedCandidates(port), { manual: state.manualLanUrl }),
|
|
492
460
|
// IPv6 直连候选单独一份:App 拿它单开一条通道、和公网中转比速度,不掺进上面那份的排序。
|
|
493
|
-
|
|
461
|
+
// 手填的那条同样缀在最后(只追加、不删网卡候选),手机每次核对连接信息都重新拿到它。
|
|
462
|
+
safeIpv6Candidates: (port) => withManualIpv6Candidate(ipv6Candidates(port), state.manualIpv6Url),
|
|
494
463
|
lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
|
|
495
464
|
// 配对那条路由自己要签凭据:core 是签 token 的实现,pairingApproval 决定这台机器
|
|
496
465
|
// 收不收新设备('deny' 时一律拒)。两者本文件都已有,透传给移动端路由。
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 扩展开发包:让手机直接读作者放在电脑上的本地扩展目录(`$DSH_HOME/extension-dev`)。
|
|
5
|
+
*
|
|
6
|
+
* 扩展平时的产物走 App 分发系统,装到手机自己那份扩展目录里;要让作者边写边在手机上看,
|
|
7
|
+
* 得有一个「从电脑现读」的入口。这个目录就是那个入口:一个子目录一个包,`manifest.json`
|
|
8
|
+
* 必须在,目录名即 slug。
|
|
9
|
+
*
|
|
10
|
+
* 能力面只多了「读这一个固定目录」:根写死在 `dshHome()` 下面,路径由 slug 与包内相对
|
|
11
|
+
* 路径拼出来,取文件前先 resolve 再判前缀 —— 不接受的是一份任意路径,不是一个被拼坏的
|
|
12
|
+
* 字符串。仍然不暴露 shell、系统设置、写文件。
|
|
13
|
+
*
|
|
14
|
+
* 坏包一律**静默跳过**而不是报错:作者当前正在编辑的那个包大概率是半成品,它不该让整张
|
|
15
|
+
* 列表变红;超限(20MB / 200 个文件)同理,不列表就是答案。取文件那条才逐个回错误码。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require('node:crypto');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const { sendJson } = require('../shared/http-util');
|
|
23
|
+
const { dshHome } = require('../shared/paths');
|
|
24
|
+
|
|
25
|
+
/** 开发包根目录名。目录不存在时按需建出来 —— 作者要往里放东西。 */
|
|
26
|
+
const DEV_ROOT_NAME = 'extension-dev';
|
|
27
|
+
/** 目录名直接当 slug 用,所以只认这套字符;别的名字连读都不读。 */
|
|
28
|
+
const SLUG_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
29
|
+
/** 整包上限:这是「现读现看」的通道,不是分发管道,太重的包手机也不该整份拉。 */
|
|
30
|
+
const MAX_PACKAGE_BYTES = 20 * 1024 * 1024;
|
|
31
|
+
const MAX_PACKAGE_FILES = 200;
|
|
32
|
+
/** 单个文件上限:一条 base64 响应要塞进手机内存,再大就该走别的路(发布到分发系统)。 */
|
|
33
|
+
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
function devRoot() {
|
|
36
|
+
return path.join(dshHome(), DEV_ROOT_NAME);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 400 信封:与其余移动端接口同形,App 按 code 翻人话。 */
|
|
40
|
+
function fail(res, status, code, message) {
|
|
41
|
+
return sendJson(res, status, { ok: false, error: { code, message } });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** manifest 里取一个非空字符串,缺了 / 类型不对 / 空串都退回默认值。 */
|
|
45
|
+
function textField(value, fallback) {
|
|
46
|
+
return typeof value === 'string' && value ? value : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sha256Hex(raw) {
|
|
50
|
+
return crypto.createHash('sha256').update(raw).digest('hex');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 递归收集包内的普通文件,`path` 是相对包目录的 POSIX 路径。
|
|
55
|
+
*
|
|
56
|
+
* 名字以 `.` 开头的文件与目录一律不看:`.git`、`.DS_Store`、编辑器临时文件都属于作者自
|
|
57
|
+
* 己的现场,不是包内容。超限当场收手返回 false,让调用方把整包丢掉 —— 先收集完再判超限
|
|
58
|
+
* 等于把 500MB 的目录整个哈希一遍。
|
|
59
|
+
*/
|
|
60
|
+
function collectFiles(directory, acc) {
|
|
61
|
+
let entries;
|
|
62
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return false; }
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (entry.name.startsWith('.')) continue;
|
|
65
|
+
const full = path.join(directory, entry.name);
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
if (!collectFiles(full, acc)) return false;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!entry.isFile()) continue;
|
|
71
|
+
let raw;
|
|
72
|
+
try { raw = fs.readFileSync(full); } catch { continue; }
|
|
73
|
+
acc.bytes += raw.length;
|
|
74
|
+
acc.files.push({
|
|
75
|
+
path: path.relative(acc.root, full).split(path.sep).join('/'),
|
|
76
|
+
size: raw.length,
|
|
77
|
+
sha256: sha256Hex(raw),
|
|
78
|
+
});
|
|
79
|
+
if (acc.bytes > MAX_PACKAGE_BYTES || acc.files.length > MAX_PACKAGE_FILES) return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 扫一遍根目录,把每个子目录读成一个包描述。坏 manifest、超限、slug 不合规都直接跳过。
|
|
86
|
+
*/
|
|
87
|
+
function listPackages(root) {
|
|
88
|
+
let entries;
|
|
89
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
|
|
90
|
+
const packages = [];
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (!entry.isDirectory() || !SLUG_PATTERN.test(entry.name)) continue;
|
|
93
|
+
const directory = path.join(root, entry.name);
|
|
94
|
+
let manifest;
|
|
95
|
+
try {
|
|
96
|
+
manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
|
|
97
|
+
} catch { continue; }
|
|
98
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) continue;
|
|
99
|
+
const acc = { root: directory, files: [], bytes: 0 };
|
|
100
|
+
if (!collectFiles(directory, acc)) continue;
|
|
101
|
+
packages.push({
|
|
102
|
+
slug: entry.name,
|
|
103
|
+
name: textField(manifest.name, entry.name),
|
|
104
|
+
versionName: textField(manifest.versionName, textField(manifest.version, '0.0.0')),
|
|
105
|
+
apiVersion: textField(manifest.apiVersion, textField(manifest.api_version, '1')),
|
|
106
|
+
size: acc.bytes,
|
|
107
|
+
fileCount: acc.files.length,
|
|
108
|
+
files: acc.files,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return packages;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* GET /api/mobile/v1/extension/dev/packages(token)
|
|
116
|
+
*
|
|
117
|
+
* 根目录不存在就建出来再回空列表:作者第一次打开开发包页时看到的是「目录已经在这儿了」,
|
|
118
|
+
* 而不是「宿主没给我建」。
|
|
119
|
+
*/
|
|
120
|
+
async function handleDevPackages(_ctx, _req, res) {
|
|
121
|
+
const root = devRoot();
|
|
122
|
+
try { fs.mkdirSync(root, { recursive: true }); } catch { /* 建不出来就按空列表回,读的时候自然也没有 */ }
|
|
123
|
+
sendJson(res, 200, { ok: true, root, packages: listPackages(root) });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* GET /api/mobile/v1/extension/dev/file?slug=…&path=…(token)
|
|
128
|
+
*
|
|
129
|
+
* 读一个包里的一个文件,回 base64 与 sha256。sha256 与列表里那条是同一份算法,手机据此
|
|
130
|
+
* 判断「这份和我列表上看到的是不是同一版」。
|
|
131
|
+
*
|
|
132
|
+
* 路径检查是两道:先挡明显不该出现的(绝对路径、`..` 段),再 resolve 之后判前缀 ——
|
|
133
|
+
* 后者才是真正判据,前缀比较必须带上分隔符,否则 `/x/extension-dev/foobar` 会被
|
|
134
|
+
* `/x/extension-dev/foo` 认成自己的子路径。
|
|
135
|
+
*/
|
|
136
|
+
async function handleDevFile(_ctx, req, res) {
|
|
137
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
138
|
+
const slug = (url.searchParams.get('slug') || '').trim();
|
|
139
|
+
const requested = (url.searchParams.get('path') || '').trim();
|
|
140
|
+
if (!slug || !requested || !SLUG_PATTERN.test(slug)) {
|
|
141
|
+
return fail(res, 400, 'bad-request', '需要合法的 slug 与 path(path 是相对包目录的路径)');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const directory = path.join(devRoot(), slug);
|
|
145
|
+
if (!fs.existsSync(path.join(directory, 'manifest.json'))) {
|
|
146
|
+
return fail(res, 404, 'dev-package-missing', `开发包目录里没有 ${slug} 这个包(缺 manifest.json)`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (path.isAbsolute(requested) || requested.split(/[\\/]/).includes('..')) {
|
|
150
|
+
return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
|
|
151
|
+
}
|
|
152
|
+
const target = path.resolve(directory, requested);
|
|
153
|
+
if (!target.startsWith(`${directory}${path.sep}`)) {
|
|
154
|
+
return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let stats;
|
|
158
|
+
try { stats = fs.statSync(target); } catch { stats = null; }
|
|
159
|
+
if (!stats || !stats.isFile()) {
|
|
160
|
+
return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
|
|
161
|
+
}
|
|
162
|
+
if (stats.size > MAX_FILE_BYTES) {
|
|
163
|
+
return fail(res, 413, 'file-too-large', `单个文件上限 ${MAX_FILE_BYTES} 字节,这个超了`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let raw;
|
|
167
|
+
try { raw = fs.readFileSync(target); } catch {
|
|
168
|
+
return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const relative = path.relative(directory, target).split(path.sep).join('/');
|
|
172
|
+
sendJson(res, 200, {
|
|
173
|
+
ok: true,
|
|
174
|
+
path: relative,
|
|
175
|
+
size: raw.length,
|
|
176
|
+
sha256: sha256Hex(raw),
|
|
177
|
+
content: raw.toString('base64'),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
devRoot,
|
|
183
|
+
listPackages,
|
|
184
|
+
handleDevPackages,
|
|
185
|
+
handleDevFile,
|
|
186
|
+
MAX_PACKAGE_BYTES,
|
|
187
|
+
MAX_PACKAGE_FILES,
|
|
188
|
+
MAX_FILE_BYTES,
|
|
189
|
+
};
|
|
@@ -329,6 +329,14 @@ function retryFailure(failure) {
|
|
|
329
329
|
return out.code !== undefined || out.message !== undefined ? out : null;
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
/** 重试行属于哪一步:步骤一关就要把它作废(App 侧按这两个数认)。 */
|
|
333
|
+
function retryOwner(data) {
|
|
334
|
+
return {
|
|
335
|
+
...(typeof data.turn === 'number' ? { turn: data.turn } : {}),
|
|
336
|
+
...(typeof data.step === 'number' ? { step: data.step } : {}),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
332
340
|
/** 列表/概览的 lastMessage 预览只取有 role 的消息项(结构化行不参与预览)。 */
|
|
333
341
|
function lastMessageOf(messages) {
|
|
334
342
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -834,6 +842,7 @@ function deriveMessages(events, sessionId, lookbackEvents) {
|
|
|
834
842
|
failure: retryFailure(e.data.failure),
|
|
835
843
|
seq,
|
|
836
844
|
time,
|
|
845
|
+
...retryOwner(e.data),
|
|
837
846
|
};
|
|
838
847
|
if (typeof e.data.delayMs === 'number') item.delayMs = e.data.delayMs;
|
|
839
848
|
retryAt.set(id, out.length);
|
|
@@ -854,8 +863,17 @@ function deriveMessages(events, sessionId, lookbackEvents) {
|
|
|
854
863
|
failure: null,
|
|
855
864
|
seq,
|
|
856
865
|
time,
|
|
866
|
+
...retryOwner(e.data),
|
|
857
867
|
});
|
|
858
868
|
}
|
|
869
|
+
} else if (e.type === 'step/end' && e.data && typeof e.data.turn === 'number' && typeof e.data.step === 'number') {
|
|
870
|
+
// 这一步关了就把它里面还停在 scheduled 的重试标成 cancelled(桌面 `isClosed` 判的也是这一步
|
|
871
|
+
// 结束)。只认 turn/end 的话,重开这个会话会看到一条永远「等待重试」的旧行,和新的并排。
|
|
872
|
+
for (const index of retryAt.values()) {
|
|
873
|
+
const row = out[index];
|
|
874
|
+
if (row && row.kind === 'retry' && row.status === 'scheduled'
|
|
875
|
+
&& row.turn === e.data.turn && row.step === e.data.step) row.status = 'cancelled';
|
|
876
|
+
}
|
|
859
877
|
} else if (e.type === 'command/run' && e.data && typeof e.data.commandId === 'string' && e.data.commandId !== '' && typeof e.data.name === 'string') {
|
|
860
878
|
// 命令行:root 行(running)。同 commandId 重复 run(罕见)只更新位置字段。
|
|
861
879
|
const id = e.data.commandId;
|