dsh-remote-hub 0.2.2 → 0.3.0

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.0** · Android 8.0 (API 26) 及以上 · 2.8 MB
22
+ **DSH Remote v4.20.1** · Android 8.0 (API 26) 及以上 · 2.8 MB
23
23
 
24
- [⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.20.0/DSH-Remote-v4.20.0.apk) ·
24
+ [⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.20.1/DSH-Remote-v4.20.1.apk) ·
25
25
  [全部版本](https://github.com/liaoguogang-hub/dsh-remote-public/releases)
26
26
 
27
27
  安装后应用名「DSH Remote」,包名 `com.dsh.remote.ngrok`(可与作者的 `DSH` 自用版共存)。
@@ -222,6 +222,19 @@ dsh-remote start # 不开 DSH 也能单独前台跑(Ctrl+C 退出)
222
222
  > 公网隧道受网络影响:`api.trycloudflare.com` 不可达时插件会跳过公网、只给局域网地址,
223
223
  > 并在 `status.json` 里写明原因,不会假装成功。
224
224
 
225
+ ### 装完之后
226
+
227
+ 插件随 DSH 启动自动工作,**不需要手动跑 connect.cmd**;装的时候没有提示是正常的,日志里会出现:
228
+
229
+ ```
230
+ dsh-remote: ready — 手机端扫码:dsh-remote qr(或浏览器打开 http://127.0.0.1:<端口>/_dsh-remote/pair)
231
+ ```
232
+
233
+ 想手动确认/手动起,就在仓库目录双击 `connect.cmd`(它和插件是同一份代码,会打印地址与 Cookie 并自动打开二维码)。
234
+
235
+ **Cookie 跨重启有效**:桥接始终向 DSH 呈现固定 authority(默认 `127.0.0.1:1815`),
236
+ 所以 DSH 换端口、桥接重启都不会让手机上的凭据失效;只有签名密钥变化或 Cookie 到期(30 天)才需要重新扫码。
237
+
225
238
  ### 扫码配对(一次性配对码)
226
239
 
227
240
  不想手抄 Cookie 就用二维码:
@@ -136,6 +136,11 @@ async function main() {
136
136
  switch (command) {
137
137
  case 'status':
138
138
  printStatus()
139
+ if (!readStatus()) {
140
+ console.log(' 下一步:dsh-remote start --open (起桥接并打开配对二维码)')
141
+ console.log(' 或双击仓库里的 connect.cmd')
142
+ console.log('')
143
+ }
139
144
  break
140
145
  case 'connect':
141
146
  if (fs.existsSync(CONNECT_FILE)) console.log(fs.readFileSync(CONNECT_FILE, 'utf8'))
package/lib/bridge.js CHANGED
@@ -149,6 +149,21 @@ export function startBridge({ listenPort, authority, upstream, pairing = null, l
149
149
  })
150
150
 
151
151
  const server = http.createServer((req, res) => {
152
+ // Health probe: lets the CLI and a second instance tell "my bridge is already running"
153
+ // apart from "some other program holds the port".
154
+ if (String(req.url || '') === '/_dsh-remote/health') {
155
+ const body = JSON.stringify({
156
+ ok: true,
157
+ service: 'dsh-remote-bridge',
158
+ bridgePort: listenPort,
159
+ authority,
160
+ upstreamPort: upstream.port || null,
161
+ })
162
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
163
+ res.end(body)
164
+ return
165
+ }
166
+
152
167
  // Pairing is served by the bridge itself: it must work before the phone has any credential,
153
168
  // and it must NOT be proxied to DSH.
154
169
  if (pairing && String(req.url || '').startsWith('/_dsh-remote/pair')) {
package/lib/discover.js CHANGED
@@ -2,13 +2,13 @@
2
2
  * Find the DSH host's HTTP port.
3
3
  *
4
4
  * The desktop host listens on a **different port every restart** (observed 1815, 5894, 14299,
5
- * 8312, 12077), so the port cannot be configured once and forgotten. It also shares the machine
6
- * with the inspector and other loopback services, so "the port is listening" is not enough —
7
- * only the real web server answers our authenticated RPC.
5
+ * 8312, 12077), so the port cannot be configured once and forgotten.
8
6
  *
9
- * Strategy: enumerate local listening ports, then probe each with the signed cookie and the
10
- * authority the bridge presents. First 200 wins. Results are cached with a TTL so a host restart
11
- * is picked up on the next failure instead of pinning a dead port forever.
7
+ * Detection is a fingerprint, not a status code: a **cookieless** `GET /` addressed with the port
8
+ * as Host answers `401 dsh web authentication required`. That sentence is DSH's own trust fence,
9
+ * and no other local service produces it. A plain 200 is worthless here — plenty of tools answer
10
+ * 200 to anything (a real false-positive source on other machines), and an authenticated probe
11
+ * cannot run before the credential exists.
12
12
  *
13
13
  * @module lib/discover
14
14
  */
@@ -17,13 +17,14 @@ import http from 'node:http'
17
17
  import { execFile } from 'node:child_process'
18
18
 
19
19
  const PROBE_TIMEOUT_MS = 1500
20
- const PROBE_PATH = '/api/session/list'
21
- const PROBE_BODY = JSON.stringify({
22
- type: 'client-request',
23
- rpcId: 'probe',
24
- method: 'session/list',
25
- payload: { args: { _request: {} } },
26
- })
20
+ const NEWLINE = String.fromCharCode(10)
21
+ const CARRIAGE = String.fromCharCode(13)
22
+
23
+ function splitLines(text) {
24
+ return String(text)
25
+ .split(NEWLINE)
26
+ .map((line) => line.split(CARRIAGE).join(''))
27
+ }
27
28
 
28
29
  function run(cmd, args, timeout = 8000) {
29
30
  return new Promise((resolve) => {
@@ -38,7 +39,7 @@ export async function listeningPorts() {
38
39
  const ports = new Set()
39
40
  if (process.platform === 'win32') {
40
41
  const out = await run('netstat', ['-ano', '-p', 'tcp'])
41
- for (const line of out.split(/\r?\n/)) {
42
+ for (const line of splitLines(out)) {
42
43
  const m = /^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)/i.exec(line)
43
44
  if (!m) continue
44
45
  const addr = m[1]
@@ -49,17 +50,16 @@ export async function listeningPorts() {
49
50
  }
50
51
  return [...ports]
51
52
  }
52
- // macOS / Linux
53
53
  const ss = await run('ss', ['-ltn'])
54
54
  if (ss) {
55
- for (const line of ss.split(/\r?\n/)) {
55
+ for (const line of splitLines(ss)) {
56
56
  const m = /\s(?:\S*):(\d+)\s/.exec(` ${line} `)
57
57
  if (m) ports.add(Number(m[1]))
58
58
  }
59
59
  }
60
60
  if (ports.size === 0) {
61
61
  const lsof = await run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'])
62
- for (const line of lsof.split(/\r?\n/)) {
62
+ for (const line of splitLines(lsof)) {
63
63
  const m = /:(\d+)\s+\(LISTEN\)/.exec(line)
64
64
  if (m) ports.add(Number(m[1]))
65
65
  }
@@ -67,29 +67,36 @@ export async function listeningPorts() {
67
67
  return [...ports].filter((p) => Number.isInteger(p) && p > 0)
68
68
  }
69
69
 
70
- /** Does `port` answer the authenticated DSH RPC? */
71
- export function probePort(port, { authority, cookie }) {
70
+ /**
71
+ * Is `port` the DSH web server?
72
+ *
73
+ * Resolves true only for `401` + `dsh web authentication required`, and false for anything that
74
+ * carries our own bridge tag (a bridge would forward the same response).
75
+ */
76
+ export function probePort(port) {
72
77
  return new Promise((resolve) => {
73
78
  const req = http.request(
74
79
  {
75
80
  host: '127.0.0.1',
76
81
  port,
77
- path: PROBE_PATH,
78
- method: 'POST',
82
+ path: '/',
83
+ method: 'GET',
79
84
  timeout: PROBE_TIMEOUT_MS,
80
- headers: {
81
- 'content-type': 'application/json',
82
- 'content-length': Buffer.byteLength(PROBE_BODY),
83
- host: authority,
84
- cookie,
85
- },
85
+ headers: { host: `127.0.0.1:${port}` },
86
86
  },
87
87
  (res) => {
88
- // Another dsh-remote bridge also answers our RPC (it forwards to the real host); treating
89
- // it as upstream would chain bridges, so tagged responses are rejected.
90
88
  const isBridge = res.headers['x-dsh-remote-bridge'] === '1'
91
- res.resume()
92
- resolve(res.statusCode === 200 && !isBridge)
89
+ const chunks = []
90
+ let seen = 0
91
+ res.on('data', (chunk) => {
92
+ if (seen < 8192) chunks.push(chunk)
93
+ seen += chunk.length
94
+ })
95
+ res.on('end', () => {
96
+ if (isBridge) return resolve(false)
97
+ const head = Buffer.concat(chunks).toString('utf8')
98
+ resolve(res.statusCode === 401 && /dsh web authentication required/i.test(head))
99
+ })
93
100
  },
94
101
  )
95
102
  req.on('error', () => resolve(false))
@@ -97,7 +104,6 @@ export function probePort(port, { authority, cookie }) {
97
104
  req.destroy()
98
105
  resolve(false)
99
106
  })
100
- req.write(PROBE_BODY)
101
107
  req.end()
102
108
  })
103
109
  }
@@ -105,39 +111,28 @@ export function probePort(port, { authority, cookie }) {
105
111
  /**
106
112
  * A resolver that remembers the last known-good port.
107
113
  *
108
- * @param {object} options
109
- * @param {string} options.authority
110
- * @param {() => Promise<string>|string} options.cookie current cookie (may be re-minted)
111
- * @param {number} [options.pinned=0] skip discovery when > 0
114
+ * @param {object} [options]
115
+ * @param {number} [options.pinned=0] skip discovery entirely when > 0
112
116
  * @param {number} [options.ttlMs=30000]
117
+ * @param {number[]} [options.exclude=[]] ports that must never be treated as upstream (our bridge)
113
118
  */
114
- export function createDiscovery({ authority, cookie, pinned = 0, ttlMs = 30_000, exclude = [] }) {
119
+ export function createDiscovery({ pinned = 0, ttlMs = 30_000, exclude = [] } = {}) {
115
120
  let port = pinned || 0
116
121
  let resolvedAt = 0
117
122
  let inFlight = null
118
123
 
119
- async function resolveCookie() {
120
- try {
121
- return await cookie()
122
- } catch {
123
- return ''
124
- }
125
- }
126
-
127
124
  async function resolve(force = false) {
128
125
  if (pinned) return pinned
129
126
  if (!force && port && Date.now() - resolvedAt < ttlMs) return port
130
127
  if (inFlight) return inFlight
131
128
  inFlight = (async () => {
132
- const ck = await resolveCookie()
133
- if (!ck) return port
134
129
  const skip = new Set(exclude.filter((p) => Number.isInteger(p) && p > 0))
135
130
  const ports = (await listeningPorts()).filter((p) => !skip.has(p))
136
131
  // Probe the remembered port first: it is usually still right and saves a full sweep.
137
132
  const ordered = port && ports.includes(port) ? [port, ...ports.filter((p) => p !== port)] : ports
138
133
  for (const candidate of ordered) {
139
134
  // eslint-disable-next-line no-await-in-loop
140
- if (await probePort(candidate, { authority, cookie: ck })) {
135
+ if (await probePort(candidate)) {
141
136
  port = candidate
142
137
  resolvedAt = Date.now()
143
138
  return port
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.2.2'
19
+ export const version = '0.3.0'
20
20
 
21
21
  /** No host services are required: the plugin only needs Node builtins. */
22
22
  export const inject = []
@@ -52,6 +52,14 @@ export function apply(ctx, config = {}) {
52
52
  start({ ...config, log })
53
53
  .then((started) => {
54
54
  handle = started
55
+ if (started?.status?.bridgePort) {
56
+ // Make the next step discoverable from the log: after installing the plugin there is no
57
+ // other place telling the user how to get the phone connected.
58
+ log(
59
+ `dsh-remote: ready — 手机端扫码:dsh-remote qr(或浏览器打开 http://127.0.0.1:${started.status.bridgePort}/_dsh-remote/pair);` +
60
+ '不想装插件时也可以直接双击仓库里的 connect.cmd',
61
+ )
62
+ }
55
63
  if (closed) return handle.close()
56
64
  return undefined
57
65
  })
package/lib/supervisor.js CHANGED
@@ -107,7 +107,7 @@ function writeStatus(status) {
107
107
  * @param {object} config
108
108
  * @param {boolean} [config.enabled=true]
109
109
  * @param {number} [config.port=6300]
110
- * @param {string} [config.authority='127.0.0.1:1815']
110
+ * @param {string} [config.authority='127.0.0.1:1815'] fixed on purpose; 'auto' follows the host port
111
111
  * @param {'auto'|'off'|'ngrok'|'cloudflared'} [config.tunnel='auto']
112
112
  * @param {number} [config.upstreamPort=0]
113
113
  * @param {string} [config.domain='']
@@ -149,16 +149,21 @@ export async function start(config = {}) {
149
149
  }
150
150
 
151
151
  // The bridge listens on `port`; probing it would forward to itself, so it must be excluded
152
- // from upstream discovery.
152
+ // from upstream discovery. Discovery never blocks startup: the bridge must come up even when the
153
+ // DSH web server has not bound its port yet (plugin loads early in host boot), otherwise the
154
+ // pairing page is unreachable and the failure looks like "the plugin did nothing".
153
155
  const discovery = createDiscovery({
154
- authority,
155
- cookie: () => cookie,
156
156
  pinned: Number(upstreamPort) || 0,
157
157
  exclude: [Number(port) || 6300],
158
158
  })
159
- const initialPort = await discovery.resolve(true)
160
- if (initialPort) log(`dsh-remote: upstream host on 127.0.0.1:${initialPort}`)
161
- else log('dsh-remote: no upstream host found yet; the bridge will keep retrying')
159
+
160
+ const ipForPairing = lanAddress()
161
+ /** Public URL is filled in after the tunnel starts; pairing reads it lazily. */
162
+ let lastPublicUrl = null
163
+
164
+ /** The bridge may have fallen back to an ephemeral port; everything must report the real one. */
165
+ let bridgeInstance = null
166
+ const actualBridgePort = () => (bridgeInstance ? bridgeInstance.port : Number(port) || 6300)
162
167
 
163
168
  // Pairing: the QR carries a one-time code, not the cookie; redeeming burns it.
164
169
  const pairingStore = createPairingStore()
@@ -166,24 +171,56 @@ export async function start(config = {}) {
166
171
  store: pairingStore,
167
172
  // Read at redeem time so a tunnel URL that appears later is still handed to the phone.
168
173
  current: () => ({
169
- lan: ipForPairing ? `http://${ipForPairing}:${Number(port) || 6300}` : null,
174
+ lan: ipForPairing ? `http://${ipForPairing}:${actualBridgePort()}` : null,
170
175
  public: lastPublicUrl ?? null,
171
176
  cookie,
172
177
  }),
173
178
  }
174
179
 
175
- const ipForPairing = lanAddress()
176
- /** Public URL is filled in after the tunnel starts; pairing reads it lazily. */
177
- let lastPublicUrl = null
180
+ let bridge
181
+ try {
182
+ bridge = await startBridge({
183
+ listenPort: Number(port) || 6300,
184
+ authority,
185
+ upstream: discovery,
186
+ pairing,
187
+ log,
188
+ })
189
+ } 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
+ if (!/EADDRINUSE/.test(String(err?.message ?? err))) throw err
193
+ log(`dsh-remote: port ${port} is busy, falling back to an ephemeral port`)
194
+ bridge = await startBridge({
195
+ listenPort: 0,
196
+ authority,
197
+ upstream: discovery,
198
+ pairing,
199
+ log,
200
+ })
201
+ }
178
202
 
179
- const bridge = await startBridge({
180
- listenPort: Number(port) || 6300,
181
- authority,
182
- upstream: discovery,
183
- pairing,
184
- log,
185
- })
203
+ // Wait for the host's web server in the background: plugin mode runs before DSH binds its port.
204
+ const waitDeadline = Date.now() + 120_000
205
+ const waitForHost = async () => {
206
+ let attempts = 0
207
+ while (Date.now() < waitDeadline) {
208
+ attempts += 1
209
+ // eslint-disable-next-line no-await-in-loop
210
+ const found = await discovery.resolve(true)
211
+ if (found) {
212
+ log(`dsh-remote: upstream host on 127.0.0.1:${found} (after ${attempts} probe(s))`)
213
+ return found
214
+ }
215
+ // eslint-disable-next-line no-await-in-loop
216
+ await new Promise((r) => setTimeout(r, 1500))
217
+ }
218
+ log('dsh-remote: no DSH web server found after 2 minutes — is DSH running?')
219
+ return 0
220
+ }
221
+ const upstreamReady = waitForHost()
186
222
 
223
+ bridgeInstance = bridge
187
224
  const tunnel = await startTunnel({
188
225
  kind: tunnelKind,
189
226
  bridgePort: bridge.port,
@@ -196,6 +233,7 @@ export async function start(config = {}) {
196
233
  const ip = ipForPairing
197
234
  const status = {
198
235
  updatedAt: Date.now(),
236
+ phase: 'ready',
199
237
  bridgePort: bridge.port,
200
238
  authority,
201
239
  upstreamPort: discovery.port || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-hub",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "DSH 插件:把电脑上的 DeepSeek Harness 变成手机可访问的服务——起桥接、自动开隧道(ngrok / cloudflared)、签发 30 天 Cookie,并输出地址与凭据,配合 DSH Remote Android App 使用。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",