dsh-remote-hub 0.2.1 → 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.19.1** · 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.19.1/DSH-Remote-v4.19.1.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 就用二维码:
@@ -233,7 +246,7 @@ dsh-remote qr
233
246
  浏览器会打开 `http://127.0.0.1:6300/_dsh-remote/pair`,显示一个二维码。手机 DSH Remote 里点
234
247
  「扫码配对」,扫完自动填入地址并换取登录凭据,直接连上。
235
248
 
236
- 扫码后会自动判断:返回的是私有地址就填进「局域网」,公网地址填进「自定义」。
249
+ 扫码一次会同时写入「局域网」和「ngrok」两档地址;连接时优先局域网(同 WiFi 最快),局域网不通才走 ngrok。扫码后即保存,之后不用再扫。
237
250
 
238
251
  安全设计:
239
252
  - 二维码里**不含 Cookie**,只含一个 **10 分钟有效的一次性配对码**(`{"v":1,"u":"<地址>","c":"<配对码>","e":<过期时间>}`);
@@ -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.1'
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
  })
@@ -15,9 +15,15 @@ import QRCode from 'qrcode'
15
15
 
16
16
  export const PAIR_PREFIX = '/_dsh-remote/pair'
17
17
 
18
- /** Build the payload the phone parses. `u` is the URL the phone should talk to. */
19
- export function buildPairPayload({ url, code, expiresAt }) {
20
- return JSON.stringify({ v: 1, u: url, c: code, e: expiresAt })
18
+ /**
19
+ * Build the payload the phone parses.
20
+ *
21
+ * Both addresses are shipped together so one scan can fill both tabs and the phone can pick the
22
+ * fastest one at connect time: `l` = LAN (private address), `u` = public tunnel. Either may be null
23
+ * when that transport is unavailable that moment.
24
+ */
25
+ export function buildPairPayload({ lan, publicUrl, code, expiresAt }) {
26
+ return JSON.stringify({ v: 1, l: lan ?? null, u: publicUrl ?? null, c: code, e: expiresAt })
21
27
  }
22
28
 
23
29
  /**
@@ -67,7 +73,10 @@ export async function handlePairing({ req, res, current, store }) {
67
73
  res.end(
68
74
  JSON.stringify({
69
75
  ok: true,
70
- url: snapshot.url,
76
+ // `url` keeps the old single-address shape working; new clients read lan/public.
77
+ url: snapshot.public ?? snapshot.lan ?? null,
78
+ lan: snapshot.lan ?? null,
79
+ public: snapshot.public ?? null,
71
80
  cookie: result.cookie,
72
81
  issuedAt: result.issuedAt,
73
82
  }),
@@ -77,7 +86,7 @@ export async function handlePairing({ req, res, current, store }) {
77
86
 
78
87
  // Any other GET under the prefix hands out a fresh code.
79
88
  const { code, expiresAt, ttlMs } = store.issue(snapshot.cookie)
80
- const payload = buildPairPayload({ url: snapshot.url, code, expiresAt })
89
+ const payload = buildPairPayload({ lan: snapshot.lan, publicUrl: snapshot.public, code, expiresAt })
81
90
 
82
91
  if (url === PAIR_PREFIX + '/svg') {
83
92
  const svg = await QRCode.toString(payload, { type: 'svg', margin: 1, width: 320, errorCorrectionLevel: 'M' })
@@ -110,8 +119,8 @@ export async function handlePairing({ req, res, current, store }) {
110
119
  <p>配对码 <code>${code}</code> · ${minutes} 分钟内有效,且只能用一次</p>
111
120
  <div class="qr">${svg}</div>
112
121
  <p class="ok">扫码后自动填入地址并换取登录凭据</p>
113
- <p class="addr">本页地址:<br>${snapshot.url}</p>
114
- <p class="addr">手输备用:打开 App → 设置 → 扫码配对 → 输入配对码</p>
122
+ <p class="addr">${snapshot.lan ? `局域网:${snapshot.lan}<br>` : ''}${snapshot.public ? `公网:${snapshot.public}` : ''}</p>
123
+ <p class="addr">扫一次会同时存下局域网与公网地址,之后自动优先走局域网</p>
115
124
  </div></body></html>`
116
125
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
117
126
  res.end(html)
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,38 +149,78 @@ 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()
165
170
  const pairing = {
166
171
  store: pairingStore,
167
- // `current()` is read at redeem time so a URL that appears later (tunnel is still coming up)
168
- // is still handed to the phone.
169
- current: () => ({ url: lastPublicUrl ?? (ipForPairing ? `http://${ipForPairing}:${Number(port) || 6300}` : ''), cookie }),
172
+ // Read at redeem time so a tunnel URL that appears later is still handed to the phone.
173
+ current: () => ({
174
+ lan: ipForPairing ? `http://${ipForPairing}:${actualBridgePort()}` : null,
175
+ public: lastPublicUrl ?? null,
176
+ cookie,
177
+ }),
170
178
  }
171
179
 
172
- const ipForPairing = lanAddress()
173
- /** Public URL is filled in after the tunnel starts; pairing reads it lazily. */
174
- 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
+ }
175
202
 
176
- const bridge = await startBridge({
177
- listenPort: Number(port) || 6300,
178
- authority,
179
- upstream: discovery,
180
- pairing,
181
- log,
182
- })
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()
183
222
 
223
+ bridgeInstance = bridge
184
224
  const tunnel = await startTunnel({
185
225
  kind: tunnelKind,
186
226
  bridgePort: bridge.port,
@@ -193,6 +233,7 @@ export async function start(config = {}) {
193
233
  const ip = ipForPairing
194
234
  const status = {
195
235
  updatedAt: Date.now(),
236
+ phase: 'ready',
196
237
  bridgePort: bridge.port,
197
238
  authority,
198
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.1",
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",