dsh-remote-hub 0.1.0 → 0.2.1

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
@@ -1,5 +1,7 @@
1
1
  # DSH Remote — Android 远程控制器
2
2
 
3
+ [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/liaoguogang-hub/dsh-remote-public)
4
+
3
5
  手机原生 App,直接控制电脑上的 **DeepSeek Harness (DSH)**。不是 WebView 套壳:
4
6
  Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
5
7
 
@@ -15,6 +17,29 @@ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
15
17
 
16
18
  功能与 UI 与自用版 4.12.2 完全一致。
17
19
 
20
+ ## 下载手机端 App
21
+
22
+ **DSH Remote v4.19.1** · Android 8.0 (API 26) 及以上 · 2.8 MB
23
+
24
+ [⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.19.1/DSH-Remote-v4.19.1.apk) ·
25
+ [全部版本](https://github.com/liaoguogang-hub/dsh-remote-public/releases)
26
+
27
+ 安装后应用名「DSH Remote」,包名 `com.dsh.remote.ngrok`(可与作者的 `DSH` 自用版共存)。
28
+ 手机端不通过 npm 分发——npm 包 `dsh-remote-hub` 是电脑侧的 DSH 插件,两者配合使用:
29
+
30
+ ```
31
+ 电脑:dsh plugin --profile desktop add dsh-remote-hub → 起桥接 / 开隧道 / 签 Cookie
32
+ 手机:装上面这个 APK → 填电脑给出的地址与 Cookie → 连接
33
+ ```
34
+
35
+ ## 扫码配对(手机端)
36
+
37
+ 电脑上 `dsh-remote qr` 打开配对页 → 手机 App「设置 → 扫码配对」扫一下就自动填好地址与凭据,
38
+ 不用手抄 Cookie。
39
+
40
+ 二维码里**不含 Cookie**:只含一个 **10 分钟有效、用一次就作废**的配对码。
41
+ 相机不可用时可以点「手动输入」,填地址 + 配对码同样能连上。
42
+
18
43
  ## 功能
19
44
 
20
45
  | 能力 | 状态 |
@@ -157,10 +182,12 @@ node mint.mjs "127.0.0.1:1815" 30 # 签发 30 天 Cookie
157
182
  并把地址与 Cookie 写进 `~/.dsh/dsh-remote/`,手机 App 直接抄。
158
183
 
159
184
  ```bash
160
- # 从 NPM 安装(发布后可用,会出现在 DSH 内的「插件中心」)
161
- dsh plugin --profile web add dsh-remote-hub
185
+ # 从 NPM 安装(推荐,会出现在 DSH 内的「插件中心」)
186
+ # https://www.npmjs.com/package/dsh-remote-hub
187
+ dsh plugin --profile desktop add dsh-remote-hub
188
+ dsh plugin --profile web add dsh-remote-hub # web profile 同样可用
162
189
 
163
- # 或直接从本仓库源码安装(不需要 NPM 发布)
190
+ # 或直接从本仓库源码安装(不经过 NPM)
164
191
  dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
165
192
  ```
166
193
 
@@ -174,6 +201,8 @@ dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
174
201
  查看结果:
175
202
 
176
203
  ```bash
204
+ dsh-remote qr # 打开配对页(二维码),手机扫一下就自动填好
205
+ dsh-remote start --open # 起桥接并打印地址/Cookie,同时打开二维码页(connect.cmd 用的就是它)
177
206
  dsh-remote status # 局域网地址、公网地址、Cookie、上游端口
178
207
  dsh-remote connect # 直接打印要抄进手机的两行
179
208
  dsh-remote doctor # 逐项自检:凭据、ngrok、cloudflared、宿主端口
@@ -192,3 +221,29 @@ dsh-remote start # 不开 DSH 也能单独前台跑(Ctrl+C 退出)
192
221
 
193
222
  > 公网隧道受网络影响:`api.trycloudflare.com` 不可达时插件会跳过公网、只给局域网地址,
194
223
  > 并在 `status.json` 里写明原因,不会假装成功。
224
+
225
+ ### 扫码配对(一次性配对码)
226
+
227
+ 不想手抄 Cookie 就用二维码:
228
+
229
+ ```bash
230
+ dsh-remote qr
231
+ ```
232
+
233
+ 浏览器会打开 `http://127.0.0.1:6300/_dsh-remote/pair`,显示一个二维码。手机 DSH Remote 里点
234
+ 「扫码配对」,扫完自动填入地址并换取登录凭据,直接连上。
235
+
236
+ 扫码后会自动判断:返回的是私有地址就填进「局域网」,公网地址填进「自定义」。
237
+
238
+ 安全设计:
239
+ - 二维码里**不含 Cookie**,只含一个 **10 分钟有效的一次性配对码**(`{"v":1,"u":"<地址>","c":"<配对码>","e":<过期时间>}`);
240
+ - 兑换成功即作废,截图/偷拍泄漏的是一个立刻失效的码,而不是 30 天访问权;
241
+ - 配对码只存在内存里,插件重启即全部失效(失效安全)。
242
+
243
+ 端点(都在桥接上,不经 DSH):
244
+
245
+ | 方法 | 路径 | 作用 |
246
+ |---|---|---|
247
+ | GET | `/_dsh-remote/pair` | 配对页(二维码 HTML) |
248
+ | GET | `/_dsh-remote/pair.svg` | 只要二维码 SVG |
249
+ | POST | `/_dsh-remote/pair/redeem` | `{code}` → `{url, cookie}`;用过即失效 |
@@ -5,14 +5,16 @@
5
5
  * dsh-remote status show LAN URL, public URL, cookie and upstream port
6
6
  * dsh-remote connect print the two lines to paste into the phone
7
7
  * dsh-remote cookie print only the cookie
8
- * dsh-remote start run the supervisor in the foreground (Ctrl+C stops it)
8
+ * dsh-remote start [--open] run the supervisor in the foreground; --open 打印地址并打开配对页
9
9
  * dsh-remote stop ask a foreground `start` to stop (best effort, via status file)
10
10
  * dsh-remote doctor check every prerequisite in one pass
11
+ * dsh-remote qr open the pairing page (QR) in the default browser
11
12
  *
12
13
  * @module bin/dsh-remote
13
14
  */
14
15
 
15
16
  import fs from 'node:fs'
17
+ import { execFile } from 'node:child_process'
16
18
  import os from 'node:os'
17
19
  import path from 'node:path'
18
20
 
@@ -45,6 +47,7 @@ function printStatus() {
45
47
  console.log(` 桥接端口 ${status.bridgePort}`)
46
48
  console.log(` DSH 上游 ${status.upstreamPort ? '127.0.0.1:' + status.upstreamPort : '未找到'}`)
47
49
  console.log(` 更新时间 ${new Date(status.updatedAt).toLocaleString()}`)
50
+ if (status.bridgePort) console.log(` 扫码配对 dsh-remote qr (或 http://127.0.0.1:${status.bridgePort}/_dsh-remote/pair)`)
48
51
  if (status.cookie) {
49
52
  console.log(` Cookie 到期 ${fmtTime(status.cookieExpiresAt)}${remaining !== null ? `(还剩 ${remaining} 天)` : ''}`)
50
53
  console.log('')
@@ -58,6 +61,39 @@ function printStatus() {
58
61
  console.log('')
59
62
  }
60
63
 
64
+ function openBrowser(url) {
65
+ const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open'
66
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
67
+ return new Promise((resolve) => {
68
+ execFile(cmd, args, { windowsHide: true }, () => resolve())
69
+ })
70
+ }
71
+
72
+ /** Point the browser at the running bridge's pairing page (port comes from status, not a constant). */
73
+ async function openPairPage(bridgePort) {
74
+ const page = `http://127.0.0.1:${bridgePort}/_dsh-remote/pair`
75
+ console.log('')
76
+ console.log(' 配对页:', page)
77
+ console.log(' 手机点右上角扫码按钮,扫这个二维码即可(10 分钟内有效,用一次作废)')
78
+ console.log('')
79
+ await openBrowser(page)
80
+ }
81
+
82
+ async function qrCommand() {
83
+ const status = readStatus()
84
+ const port = status?.bridgePort ?? Number(process.env.DSH_REMOTE_PORT || 6300)
85
+ const page = `http://127.0.0.1:${port}/_dsh-remote/pair`
86
+ console.log('')
87
+ console.log(' 配对页:', page)
88
+ console.log(' 用手机 DSH Remote 的「扫码配对」扫屏幕上的二维码(10 分钟内有效,只能用一次)')
89
+ console.log('')
90
+ if (process.argv.includes('--no-open')) {
91
+ console.log(' (--no-open:未自动打开浏览器)')
92
+ return
93
+ }
94
+ await openBrowser(page)
95
+ }
96
+
61
97
  async function doctor() {
62
98
  console.log('')
63
99
  console.log(' dsh-remote 自检')
@@ -117,8 +153,36 @@ async function main() {
117
153
  case 'doctor':
118
154
  await doctor()
119
155
  break
156
+ case 'qr':
157
+ case 'pair':
158
+ await qrCommand()
159
+ break
120
160
  case 'start': {
121
- const handle = await start({ log: (m) => console.log(m) })
161
+ const wantOpen = process.argv.includes('--open')
162
+ let handle
163
+ try {
164
+ handle = await start({ log: (m) => console.log(m) })
165
+ } catch (err) {
166
+ // Port already taken: another bridge is serving; just show its pairing page.
167
+ if (/EADDRINUSE/.test(String(err?.message ?? err))) {
168
+ const running = readStatus()
169
+ const port = running?.bridgePort
170
+ if (port) {
171
+ console.log(`已有桥接在 127.0.0.1:${port} 运行,直接复用它`)
172
+ if (wantOpen) await openPairPage(port)
173
+ return
174
+ }
175
+ }
176
+ throw err
177
+ }
178
+ const status = handle.status
179
+ if (status?.cookie) {
180
+ console.log('')
181
+ console.log(' DSH 地址(局域网):', status.lan?.url ?? '(未检测到局域网 IP)')
182
+ console.log(' DSH 地址(公网) :', status.public?.url ?? `无(${status.public?.note ?? '未开启'})`)
183
+ console.log(' Cookie :', status.cookie)
184
+ }
185
+ if (wantOpen) await openPairPage(status?.bridgePort ?? Number(process.env.DSH_REMOTE_PORT || 6300))
122
186
  process.on('SIGINT', async () => {
123
187
  await handle.close()
124
188
  process.exit(0)
@@ -130,7 +194,7 @@ async function main() {
130
194
  break
131
195
  default:
132
196
  console.log(`未知命令:${command}`)
133
- console.log('可用:status | connect | cookie | doctor | start | stop')
197
+ console.log('可用:status | connect | cookie | qr | doctor | start | stop')
134
198
  process.exitCode = 1
135
199
  }
136
200
  }
package/lib/bridge.js CHANGED
@@ -20,6 +20,8 @@ import http from 'node:http'
20
20
  import net from 'node:net'
21
21
  import zlib from 'node:zlib'
22
22
 
23
+ import { handlePairing } from './pairing-routes.js'
24
+
23
25
  const LIST_PATH = '/api/session/list'
24
26
 
25
27
  function rewriteHeaders(headers, authority) {
@@ -66,7 +68,7 @@ function trimSessionList(body) {
66
68
  * @param {(msg: string) => void} [options.log]
67
69
  * @returns {Promise<{close: () => Promise<void>, port: number}>}
68
70
  */
69
- export function startBridge({ listenPort, authority, upstream, log = () => {} }) {
71
+ export function startBridge({ listenPort, authority, upstream, pairing = null, log = () => {} }) {
70
72
  const forward = (req, res, attempt = 0) =>
71
73
  new Promise((resolve) => {
72
74
  upstream.resolve(attempt > 0).then((port) => {
@@ -147,6 +149,17 @@ export function startBridge({ listenPort, authority, upstream, log = () => {} })
147
149
  })
148
150
 
149
151
  const server = http.createServer((req, res) => {
152
+ // Pairing is served by the bridge itself: it must work before the phone has any credential,
153
+ // and it must NOT be proxied to DSH.
154
+ if (pairing && String(req.url || '').startsWith('/_dsh-remote/pair')) {
155
+ handlePairing({ req, res, current: pairing.current, store: pairing.store }).catch(() => {
156
+ try {
157
+ res.writeHead(500)
158
+ res.end('dsh-remote: pairing error')
159
+ } catch {}
160
+ })
161
+ return
162
+ }
150
163
  forward(req, res).catch(() => {
151
164
  try {
152
165
  res.writeHead(502)
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.1.0'
19
+ export const version = '0.2.1'
20
20
 
21
21
  /** No host services are required: the plugin only needs Node builtins. */
22
22
  export const inject = []
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Pairing endpoints served by the bridge itself (never proxied to DSH).
3
+ *
4
+ * GET /_dsh-remote/pair → HTML page with a QR code and the address text
5
+ * GET /_dsh-remote/pair.svg → the same QR as a standalone SVG (for embedding)
6
+ * POST /_dsh-remote/pair/redeem → {code} → {publicUrl?, lanUrl, cookie, expiresAt}
7
+ *
8
+ * The QR payload is deliberately small and secret-free:
9
+ * {"v":1,"u":"https://xxx.trycloudflare.com","c":"AB12CD34EF","e":1767000000000}
10
+ *
11
+ * @module lib/pairing-routes
12
+ */
13
+
14
+ import QRCode from 'qrcode'
15
+
16
+ export const PAIR_PREFIX = '/_dsh-remote/pair'
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 })
21
+ }
22
+
23
+ /**
24
+ * Handle a pairing request.
25
+ *
26
+ * @param {object} deps
27
+ * @param {import('node:http').IncomingMessage} deps.req
28
+ * @param {import('node:http').ServerResponse} deps.res
29
+ * @param {() => {url: string, cookie: string} | null} deps.current latest URL + cookie
30
+ * @param {ReturnType<typeof import('./pairing').createPairingStore>} deps.store
31
+ * @returns {Promise<boolean>} true when the request was handled here
32
+ */
33
+ export async function handlePairing({ req, res, current, store }) {
34
+ const url = String(req.url || '')
35
+ if (!url.startsWith(PAIR_PREFIX)) return false
36
+
37
+ const snapshot = current()
38
+ if (!snapshot) {
39
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
40
+ res.end(JSON.stringify({ ok: false, reason: 'not-ready' }))
41
+ return true
42
+ }
43
+
44
+ if (url === PAIR_PREFIX + '/redeem' && req.method === 'POST') {
45
+ let body = ''
46
+ await new Promise((resolve) => {
47
+ req.on('data', (chunk) => {
48
+ body += chunk
49
+ if (body.length > 4096) req.destroy()
50
+ })
51
+ req.on('end', resolve)
52
+ req.on('error', resolve)
53
+ })
54
+ let code = ''
55
+ try {
56
+ code = JSON.parse(body || '{}').code ?? ''
57
+ } catch {
58
+ /* fall through to the unknown-code branch */
59
+ }
60
+ const result = store.redeem(code)
61
+ if (!result.ok) {
62
+ res.writeHead(410, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
63
+ res.end(JSON.stringify({ ok: false, reason: result.reason }))
64
+ return true
65
+ }
66
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
67
+ res.end(
68
+ JSON.stringify({
69
+ ok: true,
70
+ url: snapshot.url,
71
+ cookie: result.cookie,
72
+ issuedAt: result.issuedAt,
73
+ }),
74
+ )
75
+ return true
76
+ }
77
+
78
+ // Any other GET under the prefix hands out a fresh code.
79
+ const { code, expiresAt, ttlMs } = store.issue(snapshot.cookie)
80
+ const payload = buildPairPayload({ url: snapshot.url, code, expiresAt })
81
+
82
+ if (url === PAIR_PREFIX + '/svg') {
83
+ const svg = await QRCode.toString(payload, { type: 'svg', margin: 1, width: 320, errorCorrectionLevel: 'M' })
84
+ res.writeHead(200, { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'no-store' })
85
+ res.end(svg)
86
+ return true
87
+ }
88
+
89
+ const svg = await QRCode.toString(payload, { type: 'svg', margin: 2, width: 360, errorCorrectionLevel: 'M' })
90
+ const minutes = Math.round(ttlMs / 60000)
91
+ const html = `<!doctype html>
92
+ <html lang="zh"><head><meta charset="utf-8">
93
+ <meta name="viewport" content="width=device-width,initial-scale=1">
94
+ <title>DSH Remote 配对</title>
95
+ <style>
96
+ :root { color-scheme: light dark }
97
+ body { font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0;
98
+ display: flex; min-height: 100vh; align-items: center; justify-content: center;
99
+ background: #0e1116; color: #e8eaed }
100
+ .card { background: #171b22; border: 1px solid #262c36; border-radius: 16px; padding: 28px 32px; text-align: center; max-width: 460px }
101
+ h1 { font-size: 18px; margin: 0 0 4px }
102
+ p { margin: 6px 0; color: #9aa4b2; font-size: 13px }
103
+ .qr { background: #fff; padding: 12px; border-radius: 12px; display: inline-block; margin: 14px 0 10px }
104
+ code { background: #222835; padding: 2px 6px; border-radius: 5px; font-size: 13px; word-break: break-all }
105
+ .addr { font-size: 12px; color: #8b93a1; word-break: break-all; margin-top: 10px }
106
+ .ok { color: #6ee7a8 }
107
+ </style></head>
108
+ <body><div class="card">
109
+ <h1>用 DSH Remote 扫这个码</h1>
110
+ <p>配对码 <code>${code}</code> · ${minutes} 分钟内有效,且只能用一次</p>
111
+ <div class="qr">${svg}</div>
112
+ <p class="ok">扫码后自动填入地址并换取登录凭据</p>
113
+ <p class="addr">本页地址:<br>${snapshot.url}</p>
114
+ <p class="addr">手输备用:打开 App → 设置 → 扫码配对 → 输入配对码</p>
115
+ </div></body></html>`
116
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
117
+ res.end(html)
118
+ return true
119
+ }
package/lib/pairing.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * One-time pairing codes.
3
+ *
4
+ * The QR code the phone scans must never contain the 30-day cookie itself: a photo of the screen
5
+ * would hand out permanent access. Instead the QR carries a short random code with a 10 minute
6
+ * TTL, the bridge keeps code → cookie in memory, and redeeming burns the code.
7
+ *
8
+ * In-memory on purpose: a restart invalidates outstanding codes (fail safe), and nothing
9
+ * sensitive lands on disk.
10
+ *
11
+ * @module lib/pairing
12
+ */
13
+
14
+ import { randomBytes } from 'node:crypto'
15
+
16
+ const DEFAULT_TTL_MS = 10 * 60 * 1000
17
+ const MAX_ACTIVE = 20
18
+
19
+ /** Crockford-ish alphabet: no look-alike characters, so codes can be read aloud or typed. */
20
+ const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
21
+
22
+ function randomCode(length = 10) {
23
+ const bytes = randomBytes(length)
24
+ let out = ''
25
+ for (let i = 0; i < length; i++) out += ALPHABET[bytes[i] % ALPHABET.length]
26
+ return out
27
+ }
28
+
29
+ export function createPairingStore({ ttlMs = DEFAULT_TTL_MS, max = MAX_ACTIVE } = {}) {
30
+ /** @type {Map<string, {cookie: string, expiresAt: number, createdAt: number}>} */
31
+ const codes = new Map()
32
+
33
+ function gc(now = Date.now()) {
34
+ for (const [code, entry] of codes) {
35
+ if (entry.expiresAt <= now) codes.delete(code)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Create a code bound to the current cookie.
41
+ *
42
+ * @param {string} cookie
43
+ * @returns {{code: string, expiresAt: number, ttlMs: number}}
44
+ */
45
+ function issue(cookie) {
46
+ gc()
47
+ // Drop the oldest entries when the store grows: a phone that scanned an old QR should not
48
+ // keep working forever just because we never expired it.
49
+ while (codes.size >= max) {
50
+ const oldest = codes.keys().next().value
51
+ codes.delete(oldest)
52
+ }
53
+ const now = Date.now()
54
+ const code = randomCode()
55
+ const expiresAt = now + ttlMs
56
+ codes.set(code, { cookie, expiresAt, createdAt: now })
57
+ return { code, expiresAt, ttlMs }
58
+ }
59
+
60
+ /**
61
+ * Exchange a code for its cookie. The code is consumed even when it has expired, so a leaked
62
+ * code cannot be retried after the first attempt.
63
+ *
64
+ * @param {string} code
65
+ * @returns {{ok: true, cookie: string, issuedAt: number} | {ok: false, reason: 'unknown'|'expired'}}
66
+ */
67
+ function redeem(code) {
68
+ if (typeof code !== 'string' || code.length === 0) return { ok: false, reason: 'unknown' }
69
+ const normalized = code.trim().toUpperCase()
70
+ const entry = codes.get(normalized)
71
+ if (!entry) return { ok: false, reason: 'unknown' }
72
+ codes.delete(normalized)
73
+ if (entry.expiresAt <= Date.now()) return { ok: false, reason: 'expired' }
74
+ return { ok: true, cookie: entry.cookie, issuedAt: entry.createdAt }
75
+ }
76
+
77
+ return {
78
+ issue,
79
+ redeem,
80
+ gc,
81
+ get size() {
82
+ return codes.size
83
+ },
84
+ }
85
+ }
package/lib/supervisor.js CHANGED
@@ -16,6 +16,7 @@ import path from 'node:path'
16
16
  import { startBridge } from './bridge.js'
17
17
  import { cookieExpiry, mintCookie } from './cookie.js'
18
18
  import { createDiscovery } from './discover.js'
19
+ import { createPairingStore } from './pairing.js'
19
20
  import { startTunnel } from './tunnel.js'
20
21
 
21
22
  export const DATA_DIR = path.join(os.homedir(), '.dsh', 'dsh-remote')
@@ -159,10 +160,24 @@ export async function start(config = {}) {
159
160
  if (initialPort) log(`dsh-remote: upstream host on 127.0.0.1:${initialPort}`)
160
161
  else log('dsh-remote: no upstream host found yet; the bridge will keep retrying')
161
162
 
163
+ // Pairing: the QR carries a one-time code, not the cookie; redeeming burns it.
164
+ const pairingStore = createPairingStore()
165
+ const pairing = {
166
+ 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 }),
170
+ }
171
+
172
+ const ipForPairing = lanAddress()
173
+ /** Public URL is filled in after the tunnel starts; pairing reads it lazily. */
174
+ let lastPublicUrl = null
175
+
162
176
  const bridge = await startBridge({
163
177
  listenPort: Number(port) || 6300,
164
178
  authority,
165
179
  upstream: discovery,
180
+ pairing,
166
181
  log,
167
182
  })
168
183
 
@@ -174,7 +189,8 @@ export async function start(config = {}) {
174
189
  log,
175
190
  })
176
191
 
177
- const ip = lanAddress()
192
+ lastPublicUrl = tunnel.publicUrl
193
+ const ip = ipForPairing
178
194
  const status = {
179
195
  updatedAt: Date.now(),
180
196
  bridgePort: bridge.port,
@@ -184,6 +200,11 @@ export async function start(config = {}) {
184
200
  public: { url: tunnel.publicUrl, kind: tunnel.kind, ...(tunnel.note ? { note: tunnel.note } : {}) },
185
201
  cookie,
186
202
  cookieExpiresAt: expiresAt ?? cookieExpiry(cookie),
203
+ pairing: {
204
+ // Prefer the public URL: a phone on 4G cannot reach the LAN address.
205
+ url: tunnel.publicUrl ?? (ip ? `http://${ip}:${bridge.port}` : null),
206
+ page: `/_dsh-remote/pair`,
207
+ },
187
208
  }
188
209
  writeStatus(status)
189
210
 
package/lib/tunnel.js CHANGED
@@ -56,10 +56,14 @@ function which(cmd) {
56
56
 
57
57
  /** Locate an installed tunel binary, or null. */
58
58
  export function findNgrok() {
59
+ const exe = process.platform === 'win32' ? 'ngrok.exe' : 'ngrok'
59
60
  const candidates = [
60
- process.platform === 'win32' ? path.join(process.env.LOCALAPPDATA || '', 'ngrok', 'ngrok.exe') : null,
61
- path.join(DATA_DIR, process.platform === 'win32' ? 'ngrok.exe' : 'ngrok'),
62
- process.platform === 'win32' ? 'ngrok.exe' : 'ngrok',
61
+ process.platform === 'win32' ? path.join(process.env.LOCALAPPDATA || '', 'ngrok', exe) : null,
62
+ path.join(DATA_DIR, exe),
63
+ path.join(process.cwd(), 'tools', exe),
64
+ path.join(os.homedir(), 'Documents', 'DSH', 'DshRemote', 'tools', exe),
65
+ path.join(os.homedir(), 'Documents', 'DSH', '.dsh-apk-research', 'tools', exe),
66
+ exe,
63
67
  ].filter(Boolean)
64
68
  for (const c of candidates) {
65
69
  if (exists(c)) return c
@@ -69,7 +73,14 @@ export function findNgrok() {
69
73
  }
70
74
 
71
75
  export function findCloudflared() {
72
- const candidates = [CLOUDFLARED_EXE, path.join(DATA_DIR, 'cloudflared.exe')]
76
+ const exe = process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared'
77
+ const candidates = [
78
+ CLOUDFLARED_EXE,
79
+ path.join(DATA_DIR, exe),
80
+ path.join(process.cwd(), 'tools', exe),
81
+ path.join(os.homedir(), 'Documents', 'DSH', 'DshRemote', 'tools', exe),
82
+ path.join(os.homedir(), 'Documents', 'DSH', '.dsh-apk-research', 'tools', exe),
83
+ ]
73
84
  for (const c of candidates) {
74
85
  if (exists(c)) return c
75
86
  }
@@ -144,6 +155,13 @@ export async function startTunnel({ kind = 'auto', bridgePort, authority, domain
144
155
  const wantCloudflared = kind === 'auto' || kind === 'cloudflared'
145
156
 
146
157
  if (wantNgrok) {
158
+ // A tunnel that is already pointing at this bridge (the usual case for a long-running setup
159
+ // with a static ngrok domain) is reused instead of spawning a second agent.
160
+ const reused = await existingNgrokUrl(bridgePort)
161
+ if (reused) {
162
+ log(`reusing the running ngrok tunnel: ${reused}`)
163
+ return { publicUrl: reused, kind: 'ngrok', close: () => {} }
164
+ }
147
165
  const ngrok = findNgrok()
148
166
  if (ngrok) {
149
167
  const args = ['http', String(bridgePort), '--host-header', authority, '--log', 'stdout', '--log-format', 'json']
@@ -178,6 +196,24 @@ export async function startTunnel({ kind = 'auto', bridgePort, authority, domain
178
196
  return { publicUrl: null, kind: 'none', close: () => {} }
179
197
  }
180
198
 
199
+ /** Ask a running ngrok agent what it already exposes for `bridgePort`. */
200
+ async function existingNgrokUrl(bridgePort) {
201
+ try {
202
+ const res = await fetch('http://127.0.0.1:4040/api/tunnels', { signal: AbortSignal.timeout(2000) })
203
+ if (!res.ok) return null
204
+ const doc = await res.json()
205
+ const tunnels = Array.isArray(doc?.tunnels) ? doc.tunnels : []
206
+ for (const t of tunnels) {
207
+ const addr = String(t?.config?.addr ?? '')
208
+ // ngrok reports the upstream as `http://localhost:6300` / `http://127.0.0.1:6300`.
209
+ if (new RegExp(`:${bridgePort}(/|$)`).test(addr)) return t.public_url || null
210
+ }
211
+ return null
212
+ } catch {
213
+ return null
214
+ }
215
+ }
216
+
181
217
  function waitForUrl(child, timeoutMs) {
182
218
  return new Promise((resolve) => {
183
219
  let done = false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-hub",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "DSH 插件:把电脑上的 DeepSeek Harness 变成手机可访问的服务——起桥接、自动开隧道(ngrok / cloudflared)、签发 30 天 Cookie,并输出地址与凭据,配合 DSH Remote Android App 使用。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -50,5 +50,8 @@
50
50
  "publishConfig": {
51
51
  "registry": "https://registry.npmjs.org",
52
52
  "access": "public"
53
+ },
54
+ "dependencies": {
55
+ "qrcode": "^1.5.4"
53
56
  }
54
57
  }