dsh-remote-hub 0.1.0 → 0.2.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
@@ -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,21 @@ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
15
17
 
16
18
  功能与 UI 与自用版 4.12.2 完全一致。
17
19
 
20
+ ## 下载手机端 App
21
+
22
+ **DSH Remote v4.17.1** · Android 8.0 (API 26) 及以上 · 2.6 MB
23
+
24
+ [⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.17.1/DSH-Remote-v4.17.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
+
18
35
  ## 功能
19
36
 
20
37
  | 能力 | 状态 |
@@ -157,10 +174,12 @@ node mint.mjs "127.0.0.1:1815" 30 # 签发 30 天 Cookie
157
174
  并把地址与 Cookie 写进 `~/.dsh/dsh-remote/`,手机 App 直接抄。
158
175
 
159
176
  ```bash
160
- # 从 NPM 安装(发布后可用,会出现在 DSH 内的「插件中心」)
161
- dsh plugin --profile web add dsh-remote-hub
177
+ # 从 NPM 安装(推荐,会出现在 DSH 内的「插件中心」)
178
+ # https://www.npmjs.com/package/dsh-remote-hub
179
+ dsh plugin --profile desktop add dsh-remote-hub
180
+ dsh plugin --profile web add dsh-remote-hub # web profile 同样可用
162
181
 
163
- # 或直接从本仓库源码安装(不需要 NPM 发布)
182
+ # 或直接从本仓库源码安装(不经过 NPM)
164
183
  dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
165
184
  ```
166
185
 
@@ -174,6 +193,7 @@ dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
174
193
  查看结果:
175
194
 
176
195
  ```bash
196
+ dsh-remote qr # 打开配对页(二维码),手机扫一下就自动填好
177
197
  dsh-remote status # 局域网地址、公网地址、Cookie、上游端口
178
198
  dsh-remote connect # 直接打印要抄进手机的两行
179
199
  dsh-remote doctor # 逐项自检:凭据、ngrok、cloudflared、宿主端口
@@ -192,3 +212,27 @@ dsh-remote start # 不开 DSH 也能单独前台跑(Ctrl+C 退出)
192
212
 
193
213
  > 公网隧道受网络影响:`api.trycloudflare.com` 不可达时插件会跳过公网、只给局域网地址,
194
214
  > 并在 `status.json` 里写明原因,不会假装成功。
215
+
216
+ ### 扫码配对(一次性配对码)
217
+
218
+ 不想手抄 Cookie 就用二维码:
219
+
220
+ ```bash
221
+ dsh-remote qr
222
+ ```
223
+
224
+ 浏览器会打开 `http://127.0.0.1:6300/_dsh-remote/pair`,显示一个二维码。手机 DSH Remote 里点
225
+ 「扫码配对」,扫完自动填入地址并换取登录凭据,直接连上。
226
+
227
+ 安全设计:
228
+ - 二维码里**不含 Cookie**,只含一个 **10 分钟有效的一次性配对码**(`{"v":1,"u":"<地址>","c":"<配对码>","e":<过期时间>}`);
229
+ - 兑换成功即作废,截图/偷拍泄漏的是一个立刻失效的码,而不是 30 天访问权;
230
+ - 配对码只存在内存里,插件重启即全部失效(失效安全)。
231
+
232
+ 端点(都在桥接上,不经 DSH):
233
+
234
+ | 方法 | 路径 | 作用 |
235
+ |---|---|---|
236
+ | GET | `/_dsh-remote/pair` | 配对页(二维码 HTML) |
237
+ | GET | `/_dsh-remote/pair.svg` | 只要二维码 SVG |
238
+ | POST | `/_dsh-remote/pair/redeem` | `{code}` → `{url, cookie}`;用过即失效 |
@@ -8,11 +8,13 @@
8
8
  * dsh-remote start run the supervisor in the foreground (Ctrl+C stops it)
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,29 @@ 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
+ async function qrCommand() {
73
+ const status = readStatus()
74
+ const port = status?.bridgePort ?? Number(process.env.DSH_REMOTE_PORT || 6300)
75
+ const page = `http://127.0.0.1:${port}/_dsh-remote/pair`
76
+ console.log('')
77
+ console.log(' 配对页:', page)
78
+ console.log(' 用手机 DSH Remote 的「扫码配对」扫屏幕上的二维码(10 分钟内有效,只能用一次)')
79
+ console.log('')
80
+ if (process.argv.includes('--no-open')) {
81
+ console.log(' (--no-open:未自动打开浏览器)')
82
+ return
83
+ }
84
+ await openBrowser(page)
85
+ }
86
+
61
87
  async function doctor() {
62
88
  console.log('')
63
89
  console.log(' dsh-remote 自检')
@@ -117,6 +143,10 @@ async function main() {
117
143
  case 'doctor':
118
144
  await doctor()
119
145
  break
146
+ case 'qr':
147
+ case 'pair':
148
+ await qrCommand()
149
+ break
120
150
  case 'start': {
121
151
  const handle = await start({ log: (m) => console.log(m) })
122
152
  process.on('SIGINT', async () => {
@@ -130,7 +160,7 @@ async function main() {
130
160
  break
131
161
  default:
132
162
  console.log(`未知命令:${command}`)
133
- console.log('可用:status | connect | cookie | doctor | start | stop')
163
+ console.log('可用:status | connect | cookie | qr | doctor | start | stop')
134
164
  process.exitCode = 1
135
165
  }
136
166
  }
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.0'
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-hub",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -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
  }