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 +47 -3
- package/bin/dsh-remote.mjs +31 -1
- package/lib/bridge.js +14 -1
- package/lib/index.js +1 -1
- package/lib/pairing-routes.js +119 -0
- package/lib/pairing.js +85 -0
- package/lib/supervisor.js +22 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# DSH Remote — Android 远程控制器
|
|
2
2
|
|
|
3
|
+
[](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
|
|
161
|
-
|
|
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
|
-
#
|
|
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}`;用过即失效 |
|
package/bin/dsh-remote.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
}
|