dsh-mobile-plugin 0.1.63 → 0.1.65

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.
@@ -48,10 +48,14 @@ function interfaceRank(name) {
48
48
  return 3
49
49
  }
50
50
 
51
- // Structured view of the usable LAN endpoints. The relay's `normalizeLanMetadata`
52
- // and the app's `candidateUrls()` both accept this shape, so callers that want
53
- // interface metadata (for diagnostics or smarter ordering) should prefer it.
54
- function lanInterfaces(port, interfaces = os.networkInterfaces()) {
51
+ /**
52
+ * 扫一遍网卡,把 `accept` 认的 IPv4 地址收成候选(剔除虚拟/VPN 网卡、按物理网卡优先级
53
+ * 排序、去重、最多 16 条)。
54
+ *
55
+ * `lanInterfaces` 与 `publicInterfaces` 共用这一段:两者的差别**只在 accept**——一个挑私网、
56
+ * 一个挑公网。网卡黑名单与排序规则必须一模一样,否则同一台机器上两份候选会给出互相矛盾的顺序。
57
+ */
58
+ function collectEndpoints(interfaces, accept) {
55
59
  const found = []
56
60
  let order = 0
57
61
  for (const [name, entries] of Object.entries(interfaces || {})) {
@@ -60,7 +64,7 @@ function lanInterfaces(port, interfaces = os.networkInterfaces()) {
60
64
  for (const entry of entries || []) {
61
65
  if (!entry || entry.internal) continue
62
66
  if (entry.family !== 'IPv4' && entry.family !== 4) continue
63
- if (!isPrivateLanAddress(entry.address)) continue
67
+ if (!accept(entry.address)) continue
64
68
  found.push({ name, address: entry.address, order: current })
65
69
  }
66
70
  }
@@ -73,7 +77,17 @@ function lanInterfaces(port, interfaces = os.networkInterfaces()) {
73
77
  return true
74
78
  })
75
79
  .slice(0, 16)
76
- .map((item) => ({ url: `http://${item.address}:${port}`, interface: item.name, family: 'IPv4', address: item.address }))
80
+ }
81
+
82
+ function toCandidates(list, port) {
83
+ return list.map((item) => ({ url: `http://${item.address}:${port}`, interface: item.name, family: 'IPv4', address: item.address }))
84
+ }
85
+
86
+ // Structured view of the usable LAN endpoints. The relay's `normalizeLanMetadata`
87
+ // and the app's `candidateUrls()` both accept this shape, so callers that want
88
+ // interface metadata (for diagnostics or smarter ordering) should prefer it.
89
+ function lanInterfaces(port, interfaces = os.networkInterfaces()) {
90
+ return toCandidates(collectEndpoints(interfaces, isPrivateLanAddress), port)
77
91
  }
78
92
  function lanUrls(port, interfaces = os.networkInterfaces()) {
79
93
  return lanInterfaces(port, interfaces).map((candidate) => candidate.url)
@@ -82,4 +96,80 @@ function lanCandidates(port, interfaces = os.networkInterfaces()) {
82
96
  return lanInterfaces(port, interfaces)
83
97
  }
84
98
 
85
- module.exports = { ipv4Number, isPrivateLanAddress, isVpnInterface, isVirtualInterface, interfaceRank, lanInterfaces, lanUrls, lanCandidates }
99
+ /**
100
+ * 公网地址:同一遍扫描,挑出**真能从外网拨过来**的那些。
101
+ *
102
+ * 判据是「不在任何特殊网段里」,而不是「不是 RFC1918」——后者会把 169.254/16(链路本地)、
103
+ * 100.64/10(CGNAT)、198.18/15(基准测试)这些同样拨不通的地址当成公网地址发出去。
104
+ * 实测踩过:macOS 上一块多出来的网卡带着 `169.254.87.131`,被当成「公网地址」摆到面板上。
105
+ * 这份特殊网段清单和本文件开头那句「intentionally excludes VPN/CGNAT, link-local and
106
+ * benchmarking/test ranges」是同一条口径。
107
+ *
108
+ * 为什么不并进 `lanInterfaces`:那份列表的语义是「手机和这台机器在同一个 Wi-Fi 里能直连的
109
+ * 地址」,混进公网地址会改掉 App 的探测顺序与中转上报的内容(`test/lan-network.test.cjs`
110
+ * 既钉着「WAF 容器的 192.168.0.1 被当成候选」那次事故,也钉着「只有公网地址的机器候选为空」)。
111
+ *
112
+ * 需要公网地址的地方只有两处,都在取回来之后再决定怎么用:面板显示「手机该填哪个地址」,
113
+ * 以及一台只有公网 IP 的机器上给不出任何私网候选时的兜底(见 `../impl.js` 的
114
+ * `safeLanCandidates`)。
115
+ */
116
+ const NOT_PUBLIC_RANGES = [
117
+ [0x00000000, 0x00ffffff], // 0/8 “本网络”
118
+ [0x64400000, 0x647fffff], // 100.64/10 CGNAT(运营商级 NAT,外网拨不进来)
119
+ [0x7f000000, 0x7fffffff], // 127/8 回环
120
+ [0xa9fe0000, 0xa9feffff], // 169.254/16 链路本地
121
+ [0xc6120000, 0xc613ffff], // 198.18/15 基准测试
122
+ ]
123
+ function isPublicAddress(address) {
124
+ const value = ipv4Number(address)
125
+ if (value === null) return false
126
+ if (isPrivateLanAddress(address)) return false
127
+ // 224/4 起是组播与保留(含 255.255.255.255)。
128
+ if (value >= 0xe0000000) return false
129
+ return !NOT_PUBLIC_RANGES.some(([start, end]) => inRange(value, start, end))
130
+ }
131
+ function publicInterfaces(port, interfaces = os.networkInterfaces()) {
132
+ return toCandidates(collectEndpoints(interfaces, isPublicAddress), port)
133
+ }
134
+ function publicUrls(port, interfaces = os.networkInterfaces()) {
135
+ return publicInterfaces(port, interfaces).map((candidate) => candidate.url)
136
+ }
137
+ function publicCandidates(port, interfaces = os.networkInterfaces()) {
138
+ return publicInterfaces(port, interfaces)
139
+ }
140
+
141
+ /**
142
+ * 对外公布的候选地址:**先私网,一个都没有时才退到公网**。
143
+ *
144
+ * 为什么要有这一条:`lanInterfaces` 只认 RFC1918,于是「DSH 装在一台只有公网 IP 的服务器上」
145
+ * 时那份列表**恒为空** —— 二维码里一个可拨的地址都没有,「手机直连」这条路从根上走不通
146
+ * (2026-10-11 用户报的现场)。只在为空时才退:有私网地址的机器,候选列表一个字都不变。
147
+ *
148
+ * 取不到网卡(异常)时回空清单、绝不抛:它喂的是二维码与移动端的连接信息,
149
+ * 让它把整条控制面拖下水不值得。
150
+ */
151
+ function advertisedCandidates(port, interfaces = os.networkInterfaces()) {
152
+ try {
153
+ const lan = lanCandidates(port, interfaces)
154
+ if (lan.length > 0) return lan
155
+ return publicCandidates(port, interfaces)
156
+ } catch {
157
+ return []
158
+ }
159
+ }
160
+
161
+ module.exports = {
162
+ ipv4Number,
163
+ isPrivateLanAddress,
164
+ isPublicAddress,
165
+ isVpnInterface,
166
+ isVirtualInterface,
167
+ interfaceRank,
168
+ lanInterfaces,
169
+ lanUrls,
170
+ lanCandidates,
171
+ publicInterfaces,
172
+ publicUrls,
173
+ publicCandidates,
174
+ advertisedCandidates,
175
+ }
@@ -22,6 +22,12 @@ const { randomBytes } = require('node:crypto');
22
22
 
23
23
  const core = require('../relay/core.js');
24
24
  const pairingApproval = require('../relay/pairing-policy.js');
25
+ // 端口自检用的是局域网代理**同一把探针**(见 `../relay/port-allocator.cjs` 的文件头):
26
+ // 这里说「可用」,真正 listen 时就不会 EADDRINUSE,两边不会各说各话。
27
+ const { canListen } = require('../relay/port-allocator.cjs');
28
+ // 面板的「手机该填哪个地址」要两份:私网(lanCandidates)与公网(publicCandidates)。
29
+ // 两者刻意分开,理由写在 lan-network.cjs 里那两个函数头上。
30
+ const { lanCandidates, publicCandidates } = require('../relay/lan-network.cjs');
25
31
  const desktopView = require('../desktop-view.cjs');
26
32
  const readReceipt = require('../read-receipt.cjs');
27
33
  // 客户端半边的活体台账:两条绿点同步通道都是「单向轮询 + 无回执」,不记账就永远分不开
@@ -173,6 +179,10 @@ function registerControlPlaneRoutes(deps) {
173
179
  getLanPortAdjusted,
174
180
  pairingPayload,
175
181
  restartSidecar,
182
+ // 「手机上最近来过没有」。**只往本机这条状态路由上补**(和 panelUrl 一样),
183
+ // 不动 createStatusView——那份 JSON 手机端也在读,给它加字段是另一个决定。
184
+ // 缺省回 0:没有记账的宿主(老版本 / 测试里单独挂 impl)如实报「从来没来过」。
185
+ mobileActivity = () => ({ lastMobileAt: 0 }),
176
186
  // 插件 ctx。只给「桌面端说它读过了」那条路由用:它要清掉宿主侧的完成提醒。
177
187
  // 缺省 null 时那条路由降级为「只回 200、什么都不清」。
178
188
  ctx = null,
@@ -203,7 +213,16 @@ function registerControlPlaneRoutes(deps) {
203
213
  // 控制面状态里额外带上面板页的绝对地址:客户端半边(lib/client.js)那一项现在只是
204
214
  // 一个「打开配置页面」的入口,链接必须是绝对的(原因见 panelUrlOf)。地址只在这里
205
215
  // 补,不动 createStatusView —— 那份 JSON 手机端也在读,127.0.0.1 的地址对它没意义。
206
- registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req) })) })
216
+ // 面板的「手机该填哪个地址」:把检测到的网卡地址按**局域网 / 公网**分成两份报出来,
217
+ // 用户不用自己去 `ipconfig`,界面也能一眼看出该填哪个(引导第三、四页用它)。
218
+ //
219
+ // 只给面板看:移动端那份候选契约(二维码里带的 `lanUrls`)**一个字都没动**,那边的
220
+ // 私网/公网取舍见 `../relay/lan-network.cjs` 的 `advertisedCandidates`。
221
+ registerRoute({ kind: 'exact', path: '/api/relay/addresses', handler: localOnly('GET', async (_req, res) => {
222
+ const port = lanPortInUse()
223
+ sendJson(res, 200, { ok: true, port, lan: lanCandidates(port), public: publicCandidates(port) })
224
+ }) })
225
+ registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req), ...mobileActivity() })) })
207
226
  // 面板页本体:设置界面从客户端半边搬到这里,浏览器(或桌面端里的「打开配置页面」)
208
227
  // 直接打开它。三条地址吐的是同一份 HTML,分流在 React 侧(见 React/src/Root.tsx):
209
228
  // /api/relay/panel 看宿主记的「配没配过」,自己决定先走引导还是直接进主页
@@ -376,6 +395,32 @@ function registerControlPlaneRoutes(deps) {
376
395
  await syncSidecar().catch(() => undefined)
377
396
  sendJson(res, 200, status())
378
397
  }) })
398
+ // 端口自检:面板上那颗「检查端口」用的。真实地试绑一次,回答「本机现在能不能监听它」,
399
+ // 探针就是局域网代理挑端口用的那一把,所以它给的答案和真正 listen 的结果是同一件事。
400
+ //
401
+ // 只读,不写任何配置:用户点着玩的动作不该改落盘状态(要改端口走上面的 `/config`)。
402
+ // 一个例外必须认出来:同一个端口如果是这台宿主**自己**正在监听的直连端口,
403
+ // `canListen` 当然会失败,但那不是「被别的程序占用」,要单独回 `ours`。
404
+ registerRoute({ kind: 'exact', path: '/api/relay/check-port', handler: localOnly('POST', async (req, res) => {
405
+ const body = await readJson(req)
406
+ const port = Number(body?.port)
407
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
408
+ return sendJson(res, 200, { ok: true, port: null, available: false, reason: 'out-of-range' })
409
+ }
410
+ const snapshot = status()
411
+ const listening = snapshot.lan.state === 'listening'
412
+ const currentPort = snapshot.lan.port
413
+ // 宿主自己的网页端口不能当直连端口——那等于把桌面 GUI 挂到手机上(预览那条路由的
414
+ // `protectedPorts` 也排它)。**这一条光靠试绑发现不了**:实测 macOS 上别的进程占着
415
+ // `127.0.0.1:P` 时,`0.0.0.0:P` 照样绑得成功,于是「可用」会是个假答案。
416
+ if (snapshot.localPort === port) {
417
+ return sendJson(res, 200, { ok: true, port, available: false, ours: false, listening, currentPort, reason: 'host-web-port' })
418
+ }
419
+ if (listening && currentPort === port) {
420
+ return sendJson(res, 200, { ok: true, port, available: true, ours: true, listening, currentPort })
421
+ }
422
+ sendJson(res, 200, { ok: true, port, available: await canListen(port, '0.0.0.0'), ours: false, listening, currentPort })
423
+ }) })
379
424
  registerRoute({ kind: 'exact', path: '/api/relay/reconnect', handler: localOnly('POST', async (_req, res) => {
380
425
  await syncSidecar()
381
426
  sendJson(res, 200, status())
@@ -424,7 +469,10 @@ function registerControlPlaneRoutes(deps) {
424
469
  // 拿不到(无头部署 / 没有外壳的那一版)就退回这一代自己的版本号。
425
470
  registerRoute({ kind: 'exact', path: '/api/relay/update/check', handler: localOnly('POST', async (_req, res) => {
426
471
  const runtime = pluginRuntime()
427
- const snapshot = runtime ? runtime.status() : null
472
+ // 形状必须和上面那条 `update/status` **一模一样**(含 `available`)。2026-10-11 的真机现场:
473
+ // 这条只回 `runtime.status()`,面板拿它 `setHost()` 覆盖掉好状态,`available` 变成 undefined,
474
+ // 卡片当场从「宿主模式」掉进取降级那一半 —— 用户看到的现象是「有新版本却没有『立即更新』」。
475
+ const snapshot = runtime ? { available: true, ...runtime.status() } : null
428
476
  try {
429
477
  const update = await checkForUpdate({
430
478
  installedVersion: snapshot?.installedVersion ?? snapshot?.version ?? generationVersion(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
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",
@@ -15,8 +15,7 @@
15
15
  },
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "git+https://github.com/WNLUO/deepseek-harness-desktop.git",
19
- "directory": "dsh-mobile-plugin"
18
+ "url": "git+https://github.com/WNLUO/dsh-mobile-plugin.git"
20
19
  },
21
20
  "keywords": [
22
21
  "dsh",