dsh-remote-hub 0.2.0 → 0.2.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.17.1** · Android 8.0 (API 26) 及以上 · 2.6 MB
22
+ **DSH Remote v4.20.0** · Android 8.0 (API 26) 及以上 · 2.8 MB
23
23
 
24
- [⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.17.1/DSH-Remote-v4.17.1.apk) ·
24
+ [⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.20.0/DSH-Remote-v4.20.0.apk) ·
25
25
  [全部版本](https://github.com/liaoguogang-hub/dsh-remote-public/releases)
26
26
 
27
27
  安装后应用名「DSH Remote」,包名 `com.dsh.remote.ngrok`(可与作者的 `DSH` 自用版共存)。
@@ -32,6 +32,14 @@ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
32
32
  手机:装上面这个 APK → 填电脑给出的地址与 Cookie → 连接
33
33
  ```
34
34
 
35
+ ## 扫码配对(手机端)
36
+
37
+ 电脑上 `dsh-remote qr` 打开配对页 → 手机 App「设置 → 扫码配对」扫一下就自动填好地址与凭据,
38
+ 不用手抄 Cookie。
39
+
40
+ 二维码里**不含 Cookie**:只含一个 **10 分钟有效、用一次就作废**的配对码。
41
+ 相机不可用时可以点「手动输入」,填地址 + 配对码同样能连上。
42
+
35
43
  ## 功能
36
44
 
37
45
  | 能力 | 状态 |
@@ -194,6 +202,7 @@ dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
194
202
 
195
203
  ```bash
196
204
  dsh-remote qr # 打开配对页(二维码),手机扫一下就自动填好
205
+ dsh-remote start --open # 起桥接并打印地址/Cookie,同时打开二维码页(connect.cmd 用的就是它)
197
206
  dsh-remote status # 局域网地址、公网地址、Cookie、上游端口
198
207
  dsh-remote connect # 直接打印要抄进手机的两行
199
208
  dsh-remote doctor # 逐项自检:凭据、ngrok、cloudflared、宿主端口
@@ -224,6 +233,8 @@ dsh-remote qr
224
233
  浏览器会打开 `http://127.0.0.1:6300/_dsh-remote/pair`,显示一个二维码。手机 DSH Remote 里点
225
234
  「扫码配对」,扫完自动填入地址并换取登录凭据,直接连上。
226
235
 
236
+ 扫码一次会同时写入「局域网」和「ngrok」两档地址;连接时优先局域网(同 WiFi 最快),局域网不通才走 ngrok。扫码后即保存,之后不用再扫。
237
+
227
238
  安全设计:
228
239
  - 二维码里**不含 Cookie**,只含一个 **10 分钟有效的一次性配对码**(`{"v":1,"u":"<地址>","c":"<配对码>","e":<过期时间>}`);
229
240
  - 兑换成功即作废,截图/偷拍泄漏的是一个立刻失效的码,而不是 30 天访问权;
@@ -5,7 +5,7 @@
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
11
  * dsh-remote qr open the pairing page (QR) in the default browser
@@ -69,6 +69,16 @@ function openBrowser(url) {
69
69
  })
70
70
  }
71
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
+
72
82
  async function qrCommand() {
73
83
  const status = readStatus()
74
84
  const port = status?.bridgePort ?? Number(process.env.DSH_REMOTE_PORT || 6300)
@@ -148,7 +158,31 @@ async function main() {
148
158
  await qrCommand()
149
159
  break
150
160
  case 'start': {
151
- 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))
152
186
  process.on('SIGINT', async () => {
153
187
  await handle.close()
154
188
  process.exit(0)
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.0'
19
+ export const version = '0.2.2'
20
20
 
21
21
  /** No host services are required: the plugin only needs Node builtins. */
22
22
  export const inject = []
@@ -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
@@ -164,9 +164,12 @@ export async function start(config = {}) {
164
164
  const pairingStore = createPairingStore()
165
165
  const pairing = {
166
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 }),
167
+ // Read at redeem time so a tunnel URL that appears later is still handed to the phone.
168
+ current: () => ({
169
+ lan: ipForPairing ? `http://${ipForPairing}:${Number(port) || 6300}` : null,
170
+ public: lastPublicUrl ?? null,
171
+ cookie,
172
+ }),
170
173
  }
171
174
 
172
175
  const ipForPairing = lanAddress()
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.2.0",
3
+ "version": "0.2.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",