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 +13 -2
- package/bin/dsh-remote.mjs +36 -2
- package/lib/index.js +1 -1
- package/lib/pairing-routes.js +16 -7
- package/lib/supervisor.js +6 -3
- package/lib/tunnel.js +40 -4
- package/package.json +1 -1
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.
|
|
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.
|
|
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 天访问权;
|
package/bin/dsh-remote.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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 = []
|
package/lib/pairing-routes.js
CHANGED
|
@@ -15,9 +15,15 @@ import QRCode from 'qrcode'
|
|
|
15
15
|
|
|
16
16
|
export const PAIR_PREFIX = '/_dsh-remote/pair'
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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({
|
|
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"
|
|
114
|
-
<p class="addr"
|
|
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
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
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',
|
|
61
|
-
path.join(DATA_DIR,
|
|
62
|
-
process.
|
|
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
|
|
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