dsh-mobile-plugin 0.2.0 → 0.2.1

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/relay/core.js CHANGED
@@ -44,6 +44,9 @@ function defaultState() {
44
44
  // 手机优先用哪条地址(空 = 不指定)。它是 `RemoteControlSettingsSchema` 里的一项,
45
45
  // 写它不碰 sidecar(那份配置是 `impl.js` 的 `buildConfig` 逐字段拼的,不带它)。
46
46
  preferredLanAddress: '',
47
+ // 用户手填的直连地址(空 = 没填)。与上面那条同一条口径:也是 schema 里的一项、
48
+ // 也不进 sidecar 配置 —— 它只影响「报给手机的候选」与二维码,不影响隧道怎么连。
49
+ manualLanUrl: '',
47
50
  // 首次接入引导走完没有。**刻意不进 RemoteControlSettingsSchema**:它不是一项
48
51
  // 「远程控制设置」,改它不该触发 sidecar 重配(那条链上每加一个字段都要同步改
49
52
  // schema、worker 的 allowed 数组与 buildConfig,漏一处就抛 unknown field)。
@@ -344,6 +344,39 @@ function advertisedCandidates(port, interfaces = os.networkInterfaces()) {
344
344
  }
345
345
  }
346
346
 
347
+ /**
348
+ * 用户**自己填的**直连地址(设置项 `manualLanUrl`,通常是内网穿透域名)→ 候选。
349
+ *
350
+ * 为什么需要它:网卡扫描只能产出这台机器身上的 IP,域名一条都扫不出来。手机在「网络」页
351
+ * 手填的地址从前只活在手机自己那份会话里 —— 重启、换网、重新配对之后全没了,而且只要同网
352
+ * 还有一条扫出来的 IP 更快,手填的那条还会被顶掉。放进宿主配置之后,候选的唯一真源就回到
353
+ * 宿主:手机每次核对连接信息都重新拿到它,二维码里也带着它。
354
+ *
355
+ * 只挡明显不能用的:空、非 http(s)、解析不出主机。不校验端口、不要求是 IP —— 穿透域名后面
356
+ * 接的是什么由反代决定,宿主无从判断也没必要判断。
357
+ */
358
+ function manualCandidate(value) {
359
+ const url = typeof value === 'string' ? value.trim().replace(/\/+$/, '') : ''
360
+ if (!url || !/^https?:\/\/[^\s/]+/i.test(url)) return null
361
+ let host = ''
362
+ try { host = new URL(url).hostname } catch { return null }
363
+ if (!host) return null
364
+ // `address` 与网卡候选同形(面板按裸地址显示、`orderCandidates` 按地址匹配)。
365
+ return { url, address: host, interface: 'manual', family: 'IPv4' }
366
+ }
367
+
368
+ /**
369
+ * 候选 = 用户填的那条(有的话)在前 + 其余照旧。按 url 去重,**不删**任何一条 NIC 候选:
370
+ * 穿透域名不通时手机还得能顺着网卡地址摸回这台机器。值不合法时原样返回 NIC 候选,
371
+ * 不让一条坏配置把整份候选清空(那正是彻底失联的形状)。
372
+ */
373
+ function withManualCandidate(list, value) {
374
+ const manual = manualCandidate(value)
375
+ const rest = Array.isArray(list) ? list : []
376
+ if (!manual) return rest
377
+ return [manual, ...rest.filter(item => item && item.url !== manual.url)]
378
+ }
379
+
347
380
  /**
348
381
  * 把用户选中的那条排到最前面(面板上的「手机优先用这条」,存在设置项 `preferredLanAddress`)。
349
382
  *
@@ -371,6 +404,17 @@ function orderCandidates(list, preferred) {
371
404
  return ordered
372
405
  }
373
406
 
407
+ /**
408
+ * 报给手机的直连候选 —— 二维码、配对响应与 `/connection-info` 三处**只许走这一份**。
409
+ *
410
+ * 两步顺序不能反:先让「手机优先用这条」调网卡候选的顺序,再把用户手填的地址盖到最前
411
+ * (那条是用户在手机上手打的,`preferredLanAddress` 只是面板上对网卡地址的选择,压不过它)。
412
+ * 三处各写一遍的结果就是「画面上说这条、手机去试另一条」。
413
+ */
414
+ function phoneCandidates(list, options = {}) {
415
+ return withManualCandidate(orderCandidates(list, options.preferred || ''), options.manual || '')
416
+ }
417
+
374
418
  module.exports = {
375
419
  ipv4Number,
376
420
  ipv6Number,
@@ -391,5 +435,8 @@ module.exports = {
391
435
  ipv6Urls,
392
436
  ipv6Candidates,
393
437
  advertisedCandidates,
438
+ manualCandidate,
439
+ withManualCandidate,
394
440
  orderCandidates,
441
+ phoneCandidates,
395
442
  }
@@ -665,6 +665,10 @@ function registerControlPlaneRoutes(deps) {
665
665
  if (typeof body?.preferredLanAddress === 'string' && body.preferredLanAddress.length <= 64) {
666
666
  patch.preferredLanAddress = body.preferredLanAddress.trim()
667
667
  }
668
+ // 用户手填的直连地址(内网穿透域名 / 公网 IP)。空串合法 = 清掉,理由同上。
669
+ if (typeof body?.manualLanUrl === 'string' && body.manualLanUrl.length <= 200) {
670
+ patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '')
671
+ }
668
672
  await settings.update(patch)
669
673
  await syncSidecar().catch(() => undefined)
670
674
  sendJson(res, 200, status())
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  const { sendJson } = require('../http/routing.js');
23
- const { orderCandidates } = require('../relay/lan-network.cjs');
23
+ const { orderCandidates, withManualCandidate } = require('../relay/lan-network.cjs');
24
24
 
25
25
  const sessions = require('../mobile/sessions.js');
26
26
  const sessionModelOptions = require('../mobile/session-model-options.js');
@@ -183,7 +183,8 @@ function registerMobileRoutes(deps) {
183
183
  // 旧会话升级成 T1(§3.3):新 App 每次启动幂等调一次。判据、密钥归属与「为什么密钥
184
184
  // 只能明文上行一次」都在 mobile/credential-upgrade.js 的文件头里。
185
185
  route('/api/mobile/v1/device-credential/upgrade', async (_ctx, req, res) => {
186
- handleCredentialUpgrade(req, res, { instanceId: String(state.instanceId || ''), store: credentialsEntry() })
186
+ // 必须 await:这条路要现读请求体(明文老会话的字节还挂在 socket 上,见 handler 文件头)。
187
+ await handleCredentialUpgrade(req, res, { instanceId: String(state.instanceId || ''), store: credentialsEntry() })
187
188
  })
188
189
 
189
190
  route('/api/mobile/v1/connection-info', async (_ctx, _req, res) => {
@@ -192,8 +193,10 @@ function registerMobileRoutes(deps) {
192
193
  const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort
193
194
  // 用户在面板上选过的「手机优先用这条」(设置项 `preferredLanAddress`)在这里同样兑现:
194
195
  // 手机是按顺序挨个试的,所以只挪顺序、不删候选。选的那条不在了就退回宿主排序。
196
+ // 手填的直连地址再盖到最前面:那是用户明确指定的那条,`preferredLanAddress` 只调网卡候选
197
+ // 的顺序,不该把它压到后面。
195
198
  const candidates = state.lanEnabled
196
- ? orderCandidates(safeLanCandidates(port), state.preferredLanAddress)
199
+ ? withManualCandidate(orderCandidates(safeLanCandidates(port), state.preferredLanAddress), state.manualLanUrl)
197
200
  : []
198
201
  // IPv6 直连候选:单独一份、**不参与**上面的排序 —— 它和中转谁更快只有手机实测得出,
199
202
  // 宿主无从判断对端走哪条路由。与局域网那份同一个开关管(那条监听挂在同一个代理上),
@@ -227,16 +227,20 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back'])
227
227
  * 装完了但盘上版本没变(市场报成功、文件没落),这里如实返回 `installed: false`,不让调用方
228
228
  * 以为换过代码 —— 「状态说成功、代码没换」是这个仓库吃过最贵的一次亏。
229
229
  */
230
- export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, onEvent = () => {}, packageManager = () => null, agents = () => null, beforeInstall = null, now = Date.now } = {}) {
230
+ export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, auto = false, onEvent = () => {}, packageManager = () => null, agents = () => null, beforeInstall = null, now = Date.now } = {}) {
231
231
  const before = installedVersion()
232
232
  // 装归实现自己(2026-10-11 起):盘上那一代里有 `update-install.cjs` 就走它。判定(有没有
233
233
  // 新版、`link:` 来源不碰、有 agent 在跑就先别换文件)也一并交给它,别在这里抄第二份 ——
234
234
  // 外壳的自动更新与面板那颗按钮必须是同一套判定,否则会出现「手动说有新版、自动说没有」。
235
+ //
236
+ // `auto` 也透下去:自动那一轮跳过「有对话在跑就先别装」那道闸(2026-10-25 用户拍板的取舍,
237
+ // 见 `update-install.cjs` 文件头),但没有新版时它照样什么都不做。
235
238
  const selfInstall = await installedInstaller()
236
239
  if (selfInstall) {
237
240
  const outcome = await selfInstall({
238
241
  profileNodeModules: profileNodeModules(),
239
242
  force,
243
+ auto,
240
244
  onEvent,
241
245
  // 宿主发布的包管理器(优先用它装)与 agents 服务(那道闸的判据)都从外壳这一层透下去:
242
246
  // 实现在代里,它没有宿主 ctx,只有外壳有。
@@ -262,6 +266,8 @@ export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false
262
266
  }
263
267
  }
264
268
  const check = await checkUpdate(base, name, { force })
269
+ // 兜底这条路(盘上那一代还没有自装模块)**不认 `auto`**:市场接口只有 `force` 这一档,
270
+ // 而把 `force` 借给自动会连「有没有新版」一起跳过。宁可让它暂时还按老规矩在对话结束后装。
265
271
  if (check.source !== null && !updatableSource(check.source)) {
266
272
  // 本地 link / file: 装的:市场不会碰它(见 updatableSource)。如实返回,别去试。
267
273
  return {
@@ -344,6 +350,7 @@ async function runUpdate({ runtime, base, hostService = () => null, updateState,
344
350
  try {
345
351
  outcome = await installIfNeeded(base, {
346
352
  force,
353
+ auto,
347
354
  beforeInstall,
348
355
  packageManager: () => hostService('profileContext')?.packageManager ?? null,
349
356
  agents: () => hostService('agents'),
@@ -43,7 +43,14 @@
43
43
  *
44
44
  * 装是**就地替换插件文件**,正在干活的对话可能读到半个包。市场那条路有一道这样的闸
45
45
  * (`AGENTS_RUNNING`),这里也补上了 —— 判到有人跑就**不动盘**,如实报成「可再试」。
46
- * `force`(面板上的「强制更新」)是用户明说「我知道,装吧」的那一挡,会跳过它。
46
+ *
47
+ * 两档能跳过它:`force`(面板上的「强制更新」,用户明说「我知道,装吧」)与 `auto`(宿主
48
+ * 每 10 分钟那一轮自动更新)。后者是有意的取舍:手机那边几乎一直有对话在跑,按着这道闸等,
49
+ * 自动更新实际上永远轮不到 —— 而它的定义就是「没人看着也会发生」。
50
+ *
51
+ * `auto` **只**跳过这道闸:照样要有新版才装(不跳过下面那条判断),也不一开始就带发布时长
52
+ * 开关(撞上再重试一次就够了)。把 `force` 那一档借给自动会在每 10 分钟把同一个版本重装
53
+ * 一遍,那不是自动更新。
47
54
  *
48
55
  * 顺序上**先答「有没有新版」**:没东西可装时拿一句「有对话在跑」挡在前面是误导,用户会以为
49
56
  * 是那个原因,而真正的答案是「已经是最新」。
@@ -459,13 +466,14 @@ async function installLatest({
459
466
  * 把它搬到插件自己这条路上来。判到有人跑就**不动盘**,如实报成「可再试」,下个 tick 再来。
460
467
  *
461
468
  * `force` 是**用户明说「我知道,装吧」**的那一挡(面板上那颗「强制更新」):它同时跳过
462
- * 这道闸和「新发布先晾一会儿」的门槛。自动更新永远不带 force。
469
+ * 这道闸和「新发布先晾一会儿」的门槛,而且不管有没有新版都装一遍。`auto`(宿主那一轮自动
470
+ * 更新)只跳过这道闸,其余照旧 —— 见文件头。
463
471
  */
464
472
  async function installLatestIfNeeded(options = {}) {
465
- const { profileNodeModules, force = false, name = DEFAULT_NAME, agents = null } = options;
473
+ const { profileNodeModules, force = false, auto = false, name = DEFAULT_NAME, agents = null } = options;
466
474
  const beforeVersion = installedVersion(profileNodeModules, name);
467
475
  // 来源那道门排在最前:非注册表来源连「线上最新是几」都不用问 —— 答案再新也不该装,
468
- // 而且这一条同时管着手动与自动两条路(自动更新永远不带 force,照样过不去)。
476
+ // 而且这一条同时管着手动与自动两条路(`force` / `auto` 都过不去)。
469
477
  const source = declaredSource(path.dirname(profileNodeModules), name);
470
478
  const gate = installAllowedFor(source);
471
479
  if (!gate.ok) {
@@ -497,7 +505,7 @@ async function installLatestIfNeeded(options = {}) {
497
505
  check,
498
506
  };
499
507
  }
500
- if (!force) {
508
+ if (!force && !auto) {
501
509
  const running = runningAgentIds(agents);
502
510
  if (running.length > 0) {
503
511
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "type": "module",