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 +34 -2
- package/bin/dsh-remote.mjs +60 -2
- package/lib/bridge.js +9 -2
- package/lib/index.js +1 -1
- package/lib/supervisor.js +102 -4
- package/package.json +1 -1
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.
|
|
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.
|
|
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 就用二维码:
|
package/bin/dsh-remote.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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