dsh-remote-hub 0.3.0 → 0.3.2

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 CHANGED
@@ -19,9 +19,9 @@ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
19
19
 
20
20
  ## 下载手机端 App
21
21
 
22
- **DSH Remote v4.20.1** · Android 8.0 (API 26) 及以上 · 2.8 MB
22
+ **DSH Remote v4.21.0** · Android 8.0 (API 26) 及以上 · 2.8 MB
23
23
 
24
- [⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.20.1/DSH-Remote-v4.20.1.apk) ·
24
+ [⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.21.0/DSH-Remote-v4.21.0.apk) ·
25
25
  [全部版本](https://github.com/liaoguogang-hub/dsh-remote-public/releases)
26
26
 
27
27
  安装后应用名「DSH Remote」,包名 `com.dsh.remote.ngrok`(可与作者的 `DSH` 自用版共存)。
@@ -235,6 +235,38 @@ dsh-remote: ready — 手机端扫码:dsh-remote qr(或浏览器打开 http:
235
235
  **Cookie 跨重启有效**:桥接始终向 DSH 呈现固定 authority(默认 `127.0.0.1:1815`),
236
236
  所以 DSH 换端口、桥接重启都不会让手机上的凭据失效;只有签名密钥变化或 Cookie 到期(30 天)才需要重新扫码。
237
237
 
238
+ ### 升级插件(重要:必须完全重启 DSH)
239
+
240
+ ```bash
241
+ dsh plugin --profile desktop add dsh-remote-hub # 或 --profile web
242
+ ```
243
+
244
+ **升级后请完全退出 DSH 再重新打开,不要只靠插件热重载。** 原因是旧版本的桥接监听可能不会被热重载回收,
245
+ 它会继续占着端口,而这个"旧桥接"里没有新版本的路由:
246
+
247
+ | 症状 | 含义 |
248
+ |---|---|
249
+ | 配对页打不开,或打开是**乱码/无关页面** | 端口被**旧版桥接**占着,请求被它转发走了 |
250
+ | `dsh-remote status` 里 `bridgePort` 是 `0`、地址是 `http://x.x.x.x:0` | 旧版本绑不上端口的回退 bug(0.3.0 及更早) |
251
+
252
+ 0.3.1 起这些都能自愈,但仍建议完全重启,理由是会残留**旧代码的监听**:
253
+
254
+ - 绑不上端口时先探 `/_dsh-remote/health`:
255
+ - 是支持配对的本插件桥接 → 直接**采纳**,不重复开隧道;
256
+ - 是旧版桥接(没有配对路由)或别的程序 → 自动**改用临时端口**,并在日志/`status.json` 里写明真实端口;
257
+ - 插件热重载时通过进程内注册表**先关闭上一实例**的监听。
258
+
259
+ 升级后确认当前真实端口:
260
+
261
+ ```bash
262
+ dsh-remote status # 打印 bridgePort / 局域网 / 公网 / Cookie
263
+ # 或直接看健康检查
264
+ curl http://127.0.0.1:<bridgePort>/_dsh-remote/health
265
+ ```
266
+
267
+ > 提示:`cordis.patch.yml` 里的 `port` 只是**首选**端口。若被占用,实际端口会写在 `~/.dsh/dsh-remote/status.json`,
268
+ > 一切链接与二维码都以那里为准,不必手改配置。
269
+
238
270
  ### 扫码配对(一次性配对码)
239
271
 
240
272
  不想手抄 Cookie 就用二维码:
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import fs from 'node:fs'
17
+ import http from 'node:http'
17
18
  import { execFile } from 'node:child_process'
18
19
  import os from 'node:os'
19
20
  import path from 'node:path'
@@ -69,8 +70,59 @@ function openBrowser(url) {
69
70
  })
70
71
  }
71
72
 
72
- /** Point the browser at the running bridge's pairing page (port comes from status, not a constant). */
73
+ /** Is a live dsh-remote bridge answering on this port? */
74
+ function bridgeHealthy(port, timeoutMs = 1200) {
75
+ return new Promise((resolve) => {
76
+ const req = http.request(
77
+ { host: '127.0.0.1', port, path: '/_dsh-remote/health', method: 'GET', timeout: timeoutMs },
78
+ (res) => {
79
+ const chunks = []
80
+ res.on('data', (c) => chunks.push(c))
81
+ res.on('end', () => {
82
+ try {
83
+ const doc = JSON.parse(Buffer.concat(chunks).toString('utf8'))
84
+ resolve(doc?.ok ? doc : null)
85
+ } catch {
86
+ resolve(null)
87
+ }
88
+ })
89
+ },
90
+ )
91
+ req.on('error', () => resolve(null))
92
+ req.on('timeout', () => {
93
+ req.destroy()
94
+ resolve(null)
95
+ })
96
+ req.end()
97
+ })
98
+ }
99
+
100
+ /**
101
+ * Point the browser at the running bridge's pairing page.
102
+ *
103
+ * The port is verified against the bridge's own health route first: status.json can be stale after
104
+ * a crashed run or a test on another port, and opening a dead URL looks like "the QR page is broken".
105
+ */
106
+ async function resolveLiveBridge(preferred) {
107
+ const candidates = [preferred, 6300, readStatus()?.bridgePort]
108
+ .filter((p, i, arr) => Number.isInteger(p) && p > 0 && arr.indexOf(p) === i)
109
+ for (const port of candidates) {
110
+ // eslint-disable-next-line no-await-in-loop
111
+ const live = await bridgeHealthy(port)
112
+ if (live) return Number(live.bridgePort) || port
113
+ }
114
+ return null
115
+ }
116
+
73
117
  async function openPairPage(bridgePort) {
118
+ const livePort = await resolveLiveBridge(bridgePort)
119
+ if (!livePort) {
120
+ console.log('')
121
+ console.log(' 找不到正在运行的桥接:先运行 dsh-remote start,或双击 connect.cmd')
122
+ console.log('')
123
+ return
124
+ }
125
+ bridgePort = livePort
74
126
  const page = `http://127.0.0.1:${bridgePort}/_dsh-remote/pair`
75
127
  console.log('')
76
128
  console.log(' 配对页:', page)
@@ -81,7 +133,13 @@ async function openPairPage(bridgePort) {
81
133
 
82
134
  async function qrCommand() {
83
135
  const status = readStatus()
84
- const port = status?.bridgePort ?? Number(process.env.DSH_REMOTE_PORT || 6300)
136
+ const port = await resolveLiveBridge(status?.bridgePort ?? Number(process.env.DSH_REMOTE_PORT || 6300))
137
+ if (!port) {
138
+ console.log('')
139
+ console.log(' 桥接还没运行:先执行 dsh-remote start --open(或双击 connect.cmd)')
140
+ console.log('')
141
+ return
142
+ }
85
143
  const page = `http://127.0.0.1:${port}/_dsh-remote/pair`
86
144
  console.log('')
87
145
  console.log(' 配对页:', page)
package/lib/bridge.js CHANGED
@@ -155,6 +155,9 @@ export function startBridge({ listenPort, authority, upstream, pairing = null, l
155
155
  const body = JSON.stringify({
156
156
  ok: true,
157
157
  service: 'dsh-remote-bridge',
158
+ // A bridge built before pairing existed has no such route; callers use this flag to decide
159
+ // whether an already-listening port can be adopted or must be bypassed.
160
+ pairing: pairing != null,
158
161
  bridgePort: listenPort,
159
162
  authority,
160
163
  upstreamPort: upstream.port || null,
@@ -213,9 +216,13 @@ export function startBridge({ listenPort, authority, upstream, pairing = null, l
213
216
  server.once('error', reject)
214
217
  server.listen(listenPort, '0.0.0.0', () => {
215
218
  server.removeListener('error', reject)
216
- log(`bridge listening on 0.0.0.0:${listenPort} (Host: ${authority})`)
219
+ // `listenPort: 0` asks the OS for a free port; the real one must be read back, otherwise the
220
+ // status file and every printed URL would carry port 0.
221
+ const addr = server.address()
222
+ const boundPort = addr && typeof addr === 'object' ? addr.port : listenPort
223
+ log(`bridge listening on 0.0.0.0:${boundPort} (Host: ${authority})`)
217
224
  resolve({
218
- port: listenPort,
225
+ port: boundPort,
219
226
  close: () =>
220
227
  new Promise((done) => {
221
228
  server.close(() => done())
package/lib/index.js CHANGED
@@ -16,7 +16,7 @@ import { start } from './supervisor.js'
16
16
 
17
17
  export const name = 'dsh-remote-hub'
18
18
 
19
- export const version = '0.3.0'
19
+ export const version = '0.3.2'
20
20
 
21
21
  /** No host services are required: the plugin only needs Node builtins. */
22
22
  export const inject = []
package/lib/supervisor.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * @module lib/supervisor
10
10
  */
11
11
 
12
+ import http from 'node:http'
12
13
  import fs from 'node:fs'
13
14
  import os from 'node:os'
14
15
  import path from 'node:path'
@@ -101,6 +102,32 @@ function writeStatus(status) {
101
102
  fs.writeFileSync(CONNECT_FILE, lines.join(os.EOL), 'utf8')
102
103
  }
103
104
 
105
+ /** Ask whatever listens on `port` whether it is a dsh-remote bridge, and what it supports. */
106
+ function probeHealth(port, timeoutMs = 1200) {
107
+ return new Promise((resolve) => {
108
+ const req = http.request(
109
+ { host: '127.0.0.1', port, path: '/_dsh-remote/health', method: 'GET', timeout: timeoutMs },
110
+ (res) => {
111
+ const chunks = []
112
+ res.on('data', (c) => chunks.push(c))
113
+ res.on('end', () => {
114
+ try {
115
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
116
+ } catch {
117
+ resolve(null)
118
+ }
119
+ })
120
+ },
121
+ )
122
+ req.on('error', () => resolve(null))
123
+ req.on('timeout', () => {
124
+ req.destroy()
125
+ resolve(null)
126
+ })
127
+ req.end()
128
+ })
129
+ }
130
+
104
131
  /**
105
132
  * Start everything.
106
133
  *
@@ -113,6 +140,18 @@ function writeStatus(status) {
113
140
  * @param {string} [config.domain='']
114
141
  * @param {(msg: string) => void} [config.log]
115
142
  */
143
+ /**
144
+ * Servers started by this plugin, by port.
145
+ *
146
+ * Plugin reload (hot module replacement) mounts the new build while the previous instance may still
147
+ * hold its socket. Keeping the handles on globalThis lets the new instance close its predecessor in
148
+ * the same process instead of leaving a stale bridge — built from older code — owning the port.
149
+ */
150
+ function instanceRegistry() {
151
+ if (!globalThis.__dshRemoteInstances) globalThis.__dshRemoteInstances = new Map()
152
+ return globalThis.__dshRemoteInstances
153
+ }
154
+
116
155
  export async function start(config = {}) {
117
156
  const {
118
157
  enabled = true,
@@ -177,6 +216,21 @@ export async function start(config = {}) {
177
216
  }),
178
217
  }
179
218
 
219
+ /** Assigned once the tunnel exists; the registry disposer may run before/after that. */
220
+ let tunnelRef = null
221
+ const registry = instanceRegistry()
222
+ const wantedPort = Number(port) || 6300
223
+ const previous = registry.get(wantedPort)
224
+ if (previous) {
225
+ log(`dsh-remote: closing the previous bridge on ${wantedPort} (plugin reload)`)
226
+ try {
227
+ await previous.close()
228
+ } catch (err) {
229
+ log(`dsh-remote: previous bridge close failed: ${err?.message ?? err}`)
230
+ }
231
+ registry.delete(wantedPort)
232
+ }
233
+
180
234
  let bridge
181
235
  try {
182
236
  bridge = await startBridge({
@@ -187,10 +241,41 @@ export async function start(config = {}) {
187
241
  log,
188
242
  })
189
243
  } catch (err) {
190
- // The usual cause is an older bridge (or a watchdog) still holding the port. Falling back to
191
- // an ephemeral port keeps the pairing page reachable; the real port is reported everywhere.
192
244
  if (!/EADDRINUSE/.test(String(err?.message ?? err))) throw err
193
- log(`dsh-remote: port ${port} is busy, falling back to an ephemeral port`)
245
+ // Port busy. Three cases, in order of preference:
246
+ // 1. a live bridge of ours that supports pairing -> adopt it (nothing to do);
247
+ // 2. a live bridge of ours from an older build (no pairing route) -> must bypass it;
248
+ // 3. some unrelated program -> bypass it.
249
+ const wanted = Number(port) || 6300
250
+ const existing = await probeHealth(wanted)
251
+ if (existing?.ok && existing.pairing) {
252
+ log(`dsh-remote: port ${wanted} is already served by a dsh-remote bridge; adopting it`)
253
+ // Rebuild the status from the LIVE bridge's own health report instead of trusting
254
+ // status.json: a stale file (from an earlier run on another port) is exactly what made
255
+ // connect.cmd open a dead URL.
256
+ const prev = readStatus() ?? {}
257
+ const livePort = Number(existing.bridgePort) || wanted
258
+ const ip = lanAddress()
259
+ const adopted = {
260
+ updatedAt: Date.now(),
261
+ phase: 'adopted',
262
+ bridgePort: livePort,
263
+ authority: existing.authority ?? authority,
264
+ upstreamPort: existing.upstreamPort ?? null,
265
+ lan: { ip, url: ip ? `http://${ip}:${livePort}` : null },
266
+ public: prev.public ?? { url: null, kind: 'none', note: 'served by an existing bridge' },
267
+ cookie: prev.cookie ?? null,
268
+ cookieExpiresAt: prev.cookieExpiresAt ?? null,
269
+ pairing: { url: prev.public?.url ?? (ip ? `http://${ip}:${livePort}` : null), page: '/_dsh-remote/pair' },
270
+ }
271
+ writeStatus(adopted)
272
+ return { status: adopted, close: async () => {} }
273
+ }
274
+ if (existing?.ok) {
275
+ log('dsh-remote: port is held by an old bridge WITHOUT pairing support; using another port')
276
+ } else {
277
+ log(`dsh-remote: port ${wanted} is busy (not a dsh-remote bridge); using another port`)
278
+ }
194
279
  bridge = await startBridge({
195
280
  listenPort: 0,
196
281
  authority,
@@ -198,6 +283,7 @@ export async function start(config = {}) {
198
283
  pairing,
199
284
  log,
200
285
  })
286
+ log(`dsh-remote: bound to ${bridge.port} instead; use the URLs below, not the old port`)
201
287
  }
202
288
 
203
289
  // Wait for the host's web server in the background: plugin mode runs before DSH binds its port.
@@ -220,7 +306,6 @@ export async function start(config = {}) {
220
306
  }
221
307
  const upstreamReady = waitForHost()
222
308
 
223
- bridgeInstance = bridge
224
309
  const tunnel = await startTunnel({
225
310
  kind: tunnelKind,
226
311
  bridgePort: bridge.port,
@@ -229,6 +314,19 @@ export async function start(config = {}) {
229
314
  log,
230
315
  })
231
316
 
317
+ tunnelRef = tunnel
318
+ bridgeInstance = bridge
319
+ registry.set(bridge.port, {
320
+ close: async () => {
321
+ try {
322
+ tunnelRef?.close?.()
323
+ } catch {}
324
+ try {
325
+ await bridge.close()
326
+ } catch {}
327
+ },
328
+ })
329
+
232
330
  lastPublicUrl = tunnel.publicUrl
233
331
  const ip = ipForPairing
234
332
  const status = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-hub",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "DSH 插件:把电脑上的 DeepSeek Harness 变成手机可访问的服务——起桥接、自动开隧道(ngrok / cloudflared)、签发 30 天 Cookie,并输出地址与凭据,配合 DSH Remote Android App 使用。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",