dsh-remote-hub 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 liaoguogang-hub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,238 @@
1
- # Temporary Holding Version
1
+ # DSH Remote — Android 远程控制器
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/liaoguogang-hub/dsh-remote-public)
4
+
5
+ 手机原生 App,直接控制电脑上的 **DeepSeek Harness (DSH)**。不是 WebView 套壳:
6
+ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
7
+
8
+ 这是**公共版**,与作者自用版的唯一差别:
9
+
10
+ | | 自用版 | 本仓库(公共版) |
11
+ |---|---|---|
12
+ | 连接方式 | 局域网 / 自建中继 / 自定义 | 局域网 / **ngrok 隧道** / 自定义 |
13
+ | 内置服务器地址 | 有 | **无** |
14
+ | 内置登录凭证 | 有(30 天 cookie) | **无** |
15
+ | 包名 | `com.dsh.remote` | `com.dsh.remote.ngrok`(可与自用版共存) |
16
+ | 应用名 / APK | `DSH` / `DSH-Remote-v*.apk` | `DSH Remote` / `DSH Remote-v*.apk` |
17
+
18
+ 功能与 UI 与自用版 4.12.2 完全一致。
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
+
35
+ ## 功能
36
+
37
+ | 能力 | 状态 |
38
+ |---|---|
39
+ | 三种连接方式(局域网 / ngrok / 自定义),地址与凭证首次填写后本地保存 | ✅ |
40
+ | 会话列表:搜索、排序(最近更新 / 手动)、归档筛选(隐藏 / 全部 / 仅归档) | ✅ |
41
+ | 会话列表默认显示最近 10 条,「展开其余 N 个会话」每次 +5 | ✅ |
42
+ | 长按会话:重命名 / 归档会话(或取消归档) | ✅ |
43
+ | 发消息、逐字流式回复、随时中断 | ✅ |
44
+ | 历史分页(「加载更早的消息」)与本地缓存(先显示上次内容再同步) | ✅ |
45
+ | 模型选择(分组目录 + 切换) | ✅ |
46
+ | 权限模式 | ⚠️ 只能读取当前值(DSH 未开放远程切换权限的接口) |
47
+ | 自定义地址历史(点选填入 / 删除) | ✅ |
48
+
49
+ ## 三步连上(推荐:一键脚本)
50
+
51
+ 1. **电脑上双击 `connect.cmd`**(和 App 一起发布的文件)。
52
+ 它会自动做四件事:起桥接 → 签发 30 天 Cookie → 尽量开公网隧道 → 打印结果。
53
+ 2. 等黑窗口打印出这两行:
54
+ - `DSH 地址: http://192.168.x.x:6300`(同一 WiFi,最快)
55
+ - `Cookie: dsh-auth-…`(已自动复制到剪贴板)
56
+ 如果网络允许,还会多给一行公网地址 `https://xxx.trycloudflare.com`(人在外面时用)。
57
+ 3. **App 里选「自定义」档** → 把地址粘进「DSH 地址」框 → Cookie 粘进「Cookie」框 → 点连接。
58
+
59
+ > 电脑端那个黑窗口要保持开着;关掉 = 断开手机。下次用重新双击一次,抄新的地址和 Cookie 即可。
60
+ > 结果同时会写到同目录的 `connect-info.txt`。
61
+
62
+ ### 公网隧道说明
63
+
64
+ 脚本按这个顺序尝试,**局域网地址永远可用**,公网则尽力而为:
65
+
66
+ | 情况 | 结果 |
67
+ |---|---|
68
+ | 装了 `ngrok` 且在 PATH 里 | 用 ngrok 开隧道 |
69
+ | 有 `cloudflared`(脚本会自动找/下载)且 `api.trycloudflare.com` 可达 | 免费 trycloudflare 地址,免注册 |
70
+ | 两者都不行(常见于受限网络) | 只给局域网地址,并提示需要自备内网穿透(cpolar / natapp / 花生壳 / 自建 frp) |
71
+
72
+ ### 用 ngrok 拿一个永久固定域名(可选,需注册)
73
+
74
+ cloudflared 的免费 quick tunnel **每次重启地址都会变**;想要固定域名就用 ngrok 免费账号。
75
+
76
+
77
+ ![ngrok 注册要点:左栏三处](docs/ngrok-dashboard-annotated.png)
78
+
79
+ 上图红框就是全部要点:① Share Localhost ② Your Authtoken ③ Domains。
80
+
81
+ **注册(约 2 分钟)**
82
+
83
+ 1. 打开 <https://dashboard.ngrok.com/get-started/setup>,在首页选 **Share Localhost**
84
+ (「Put your local app on a public URL」——它明确写着 *stable, auth-protected URL*);
85
+ 2. 跟着 **Get started** 走完,进 dashboard 后取两样东西:
86
+ - **Authtoken**:左侧 `Getting Started` → `Your Authtoken`
87
+ <https://dashboard.ngrok.com/get-started/your-authtoken>
88
+ - **免费固定域名**:左侧 `Universal Gateway` → `Domains`,免费账号自带一个
89
+ `xxxx-yyyy.ngrok-free.app` <https://dashboard.ngrok.com/domains>
90
+ 3. 下载 ngrok 单文件(同样免安装、不需要管理员权限),放进 `tools\ngrok.exe`,或装到 PATH。
91
+
92
+ **配置(只需一次)**
93
+
94
+ ```powershell
95
+ tools\ngrok.exe config add-authtoken <你的 Authtoken>
96
+ ```
97
+
98
+ 然后双击 `connect.cmd`:它会自己发现 ngrok、问你一次固定域名(记在
99
+ `%LOCALAPPDATA%\dsh-remote\ngrok-domain.txt`),之后每次都复用同一个地址。
100
+
101
+ > - **Host 必须改写**:脚本用 `--host-header=127.0.0.1:1815`,否则 DSH 的信任栅栏会 403,
102
+ > 而且 cookie 的 authority 对不上。
103
+ > - **不要加 ngrok 的 Basic Auth**:App 是用 DSH 的签名 cookie 认证的,Basic Auth 会让每个
104
+ > API 调用先被 ngrok 拦下(401),App 连不上。安全性由 30 天签名的 cookie 保证。
105
+ > - **免费额度**:约 40 连接/分钟、1 GB/月。App 是长连接 + 流式输出,日常够用;
106
+ > 偶发断连多半是额度或连接数超限,可换付费或 cloudflared。
107
+
108
+ ### 自己下载 cloudflared(可选,单文件免安装、不需要管理员权限)
109
+
110
+ 脚本会优先在 `connect.cmd` 同目录、`tools\`、`%LOCALAPPDATA%\dsh-remote\` 等位置找
111
+ `cloudflared.exe`;找不到才下载。想自己放一份,按下面来:
112
+
113
+ ```powershell
114
+ # 1) 镜像优先:国内直连 GitHub 下 52 MB 基本必超时
115
+ $url = "https://ghfast.top/https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
116
+ # 官方直连(网络允许时):
117
+ # $url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
118
+
119
+ $dest = "tools\cloudflared.exe"
120
+ New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
121
+ Invoke-WebRequest -Uri $url -OutFile $dest -TimeoutSec 300 -UseBasicParsing
122
+
123
+ # 2) 一定要验签:镜像可能给错文件,但签名伪造不了
124
+ Get-AuthenticodeSignature $dest | Format-List Status, SignerCertificate
125
+ ```
126
+
127
+ 合格标准:`Status = Valid`,`Subject` 里含 `Cloudflare, Inc.`。
128
+ 参考值(2026.10.5 版):`55,343,328` 字节,
129
+ SHA256 `DB4E4C413612A0916F3E1679D3CAAF376F94911952D163028FB726FB2E0516A9`。
130
+
131
+ > 脚本自己下载时也会做同样的验签(`Get-AuthenticodeSignature` + 主体含 Cloudflare),
132
+ > 校验不过就丢弃并换下一个源 —— **不要用哈希对比代替验签**,镜像可以把哈希一起改掉。
133
+
134
+ ### 手工方式(想自己控制时)
135
+
136
+ ```bash
137
+ node lan-proxy.mjs 6300 127.0.0.1:1815 # 桥接:把 Host 改写成 DSH 认可的 127.0.0.1:1815
138
+ ngrok http 6300 --host-header=127.0.0.1:1815 # 或 cloudflared tunnel --url http://127.0.0.1:6300
139
+ node mint.mjs "127.0.0.1:1815" 30 # 签发 30 天 Cookie
140
+ ```
141
+
142
+ ```text
143
+ 浏览器打开 DSH 页面 → F12 → Application → Cookies → 复制会话 cookie
144
+ (cookie 只对签发时的 authority 有效,所以桥接必须改写 Host 到同一个值)
145
+ ```
146
+
147
+ ## 安全说明
148
+
149
+ - **本仓库不包含任何地址、Token 或 Cookie**:`DshDefaults.kt` 里全是空字符串,首次连接在 App 里填。
150
+ - **Cookie 由你自己的电脑签发**:脚本用 `~/.dsh/.credentials.yaml` 里的密钥签一个 30 天有效的凭据,
151
+ 只在你自己机器和手机之间流动,不要提交到任何仓库。
152
+ - **局域网默认是明文 HTTP**:`lan-proxy.mjs` 监听 `0.0.0.0:6300`,同一 WiFi 内 Anyone 能访问它,
153
+ 所以请只在可信网络里用局域网模式;要跨网请用 ngrok / cloudflared 这类带 TLS 的隧道。
154
+ - **App 允许明文与用户证书**(`network_security_config.xml`):DSH 通常跑在明文 HTTP 上,
155
+ 自签 CA 的中转也要能信任;代价是它不会阻止你连明文地址,所以地址来源要自己把关。
156
+ - **别把 ngrok 的 Basic Auth 和 DSH 的 Cookie 混用**:App 只带 DSH 的 Cookie,
157
+ Basic Auth 会让每个请求先被隧道 401 拦掉。
158
+
159
+ ## 构建
160
+
161
+ ```bash
162
+ ./gradlew assembleRelease # 产物:dist/DSH Remote-v<version>.apk
163
+ ```
164
+
165
+ 需要 JDK 17 与 Android SDK(`local.properties` 指向本机 SDK)。
166
+
167
+ ## 版本
168
+
169
+ 当前 `4.12.2`(versionCode 58),与自用版 4.12.2 功能一致。
170
+
171
+ ## 作为 DSH 插件安装(推荐)
172
+
173
+ 本仓库根目录同时是一个 **DSH plugin**(Node 侧),装进 DSH 后由宿主负责起桥接、开隧道、签 Cookie,
174
+ 并把地址与 Cookie 写进 `~/.dsh/dsh-remote/`,手机 App 直接抄。
175
+
176
+ ```bash
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 同样可用
181
+
182
+ # 或直接从本仓库源码安装(不经过 NPM)
183
+ dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
184
+ ```
185
+
186
+ 装完重启 `dsh web`(或桌面端),插件会自动:
187
+
188
+ 1. 起桥接:`0.0.0.0:6300`,把 Host 改写成 `127.0.0.1:1815`(Cookie 因此不会因端口变化失效);
189
+ 2. 自动发现 DSH 宿主端口(宿主每次重启都换端口,插件每次失败会重新扫一遍);
190
+ 3. 开隧道:**有 ngrok 就用 ngrok**(固定域名),否则自动下载并验签 cloudflared 开免注册 quick tunnel;
191
+ 4. 签发 30 天 Cookie,写到 `~/.dsh/dsh-remote/status.json` 与 `connect.txt`。
192
+
193
+ 查看结果:
194
+
195
+ ```bash
196
+ dsh-remote qr # 打开配对页(二维码),手机扫一下就自动填好
197
+ dsh-remote status # 局域网地址、公网地址、Cookie、上游端口
198
+ dsh-remote connect # 直接打印要抄进手机的两行
199
+ dsh-remote doctor # 逐项自检:凭据、ngrok、cloudflared、宿主端口
200
+ dsh-remote start # 不开 DSH 也能单独前台跑(Ctrl+C 退出)
201
+ ```
202
+
203
+ 插件配置(profile 行里覆盖即可):
204
+
205
+ | 键 | 默认 | 作用 |
206
+ |---|---|---|
207
+ | `enabled` | `true` | 关掉插件但不卸载 |
208
+ | `port` | `6300` | 桥接监听端口(手机连这个) |
209
+ | `authority` | `127.0.0.1:1815` | 桥接呈现的 Host,Cookie 绑定它 |
210
+ | `tunnel` | `auto` | `auto` / `off` / `ngrok` / `cloudflared` |
211
+ | `upstreamPort` | `0` | 固定 DSH 宿主端口,`0` 表示自动发现 |
212
+
213
+ > 公网隧道受网络影响:`api.trycloudflare.com` 不可达时插件会跳过公网、只给局域网地址,
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}`;用过即失效 |
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-remote CLI — inspect or drive the bridge/tunnel without the DSH UI.
4
+ *
5
+ * dsh-remote status show LAN URL, public URL, cookie and upstream port
6
+ * dsh-remote connect print the two lines to paste into the phone
7
+ * dsh-remote cookie print only the cookie
8
+ * dsh-remote start run the supervisor in the foreground (Ctrl+C stops it)
9
+ * dsh-remote stop ask a foreground `start` to stop (best effort, via status file)
10
+ * dsh-remote doctor check every prerequisite in one pass
11
+ * dsh-remote qr open the pairing page (QR) in the default browser
12
+ *
13
+ * @module bin/dsh-remote
14
+ */
15
+
16
+ import fs from 'node:fs'
17
+ import { execFile } from 'node:child_process'
18
+ import os from 'node:os'
19
+ import path from 'node:path'
20
+
21
+ import { credentialsPath, mintCookie, readSigningSecret } from '../lib/cookie.js'
22
+ import { listeningPorts, probePort } from '../lib/discover.js'
23
+ import { findCloudflared, findNgrok } from '../lib/tunnel.js'
24
+ import { CONNECT_FILE, STATUS_FILE, readStatus, start } from '../lib/supervisor.js'
25
+
26
+ const args = process.argv.slice(2)
27
+ const command = args[0] || 'status'
28
+
29
+ function fmtTime(ms) {
30
+ if (!ms) return '?'
31
+ const d = new Date(ms)
32
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
33
+ }
34
+
35
+ function printStatus() {
36
+ const status = readStatus()
37
+ if (!status) {
38
+ console.log('还没有运行记录。先运行: dsh-remote start')
39
+ return
40
+ }
41
+ const remaining = status.cookieExpiresAt ? Math.round((status.cookieExpiresAt - Date.now()) / 86400000) : null
42
+ console.log('')
43
+ console.log(' DSH Remote 状态')
44
+ console.log(' ' + '-'.repeat(52))
45
+ console.log(` 局域网地址 ${status.lan?.url ?? '(未检测到局域网 IP)'}`)
46
+ console.log(` 公网地址 ${status.public?.url ?? `无(${status.public?.note ?? '未开启'})`}`)
47
+ console.log(` 桥接端口 ${status.bridgePort}`)
48
+ console.log(` DSH 上游 ${status.upstreamPort ? '127.0.0.1:' + status.upstreamPort : '未找到'}`)
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)`)
51
+ if (status.cookie) {
52
+ console.log(` Cookie 到期 ${fmtTime(status.cookieExpiresAt)}${remaining !== null ? `(还剩 ${remaining} 天)` : ''}`)
53
+ console.log('')
54
+ console.log(' 把下面两样填进手机 App:')
55
+ console.log(` 地址 ${status.public?.url ?? status.lan?.url ?? ''}`)
56
+ console.log(` Cookie ${status.cookie}`)
57
+ } else if (status.error) {
58
+ console.log(` 错误 ${status.error}`)
59
+ }
60
+ console.log(' ' + '-'.repeat(52))
61
+ console.log('')
62
+ }
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
+
87
+ async function doctor() {
88
+ console.log('')
89
+ console.log(' dsh-remote 自检')
90
+ console.log(' ' + '-'.repeat(52))
91
+ const cred = credentialsPath()
92
+ let secret = null
93
+ try {
94
+ secret = readSigningSecret(cred)
95
+ console.log(` ✔ 凭据文件 ${cred}`)
96
+ } catch (err) {
97
+ console.log(` ✘ 凭据文件 ${err.message}`)
98
+ }
99
+ const ngrok = findNgrok()
100
+ console.log(` ${ngrok ? '✔' : '·'} ngrok ${ngrok ?? '未安装(可选,有则优先用固定域名)'}`)
101
+ const cf = findCloudflared()
102
+ console.log(` ${cf ? '✔' : '·'} cloudflared ${cf ?? '未安装(首次运行会自动下载并验签)'}`)
103
+ if (secret) {
104
+ try {
105
+ const { cookie } = mintCookie('127.0.0.1:1815', { secret, days: 30 })
106
+ const bridgePort = Number(process.env.DSH_REMOTE_PORT || 6300)
107
+ const ports = (await listeningPorts()).filter((p) => p !== bridgePort)
108
+ let found = null
109
+ for (const p of ports) {
110
+ // eslint-disable-next-line no-await-in-loop
111
+ if (await probePort(p, { authority: '127.0.0.1:1815', cookie })) {
112
+ found = p
113
+ break
114
+ }
115
+ }
116
+ console.log(` ${found ? '✔' : '✘'} DSH 宿主端口 ${found ? '127.0.0.1:' + found : '未找到(DSH 开着吗?)'}`)
117
+ } catch (err) {
118
+ console.log(` ✘ 探测失败 ${err.message}`)
119
+ }
120
+ }
121
+ console.log(' ' + '-'.repeat(52))
122
+ console.log('')
123
+ }
124
+
125
+ async function main() {
126
+ switch (command) {
127
+ case 'status':
128
+ printStatus()
129
+ break
130
+ case 'connect':
131
+ if (fs.existsSync(CONNECT_FILE)) console.log(fs.readFileSync(CONNECT_FILE, 'utf8'))
132
+ else printStatus()
133
+ break
134
+ case 'cookie': {
135
+ const status = readStatus()
136
+ if (status?.cookie) console.log(status.cookie)
137
+ else {
138
+ const { cookie } = mintCookie('127.0.0.1:1815')
139
+ console.log(cookie)
140
+ }
141
+ break
142
+ }
143
+ case 'doctor':
144
+ await doctor()
145
+ break
146
+ case 'qr':
147
+ case 'pair':
148
+ await qrCommand()
149
+ break
150
+ case 'start': {
151
+ const handle = await start({ log: (m) => console.log(m) })
152
+ process.on('SIGINT', async () => {
153
+ await handle.close()
154
+ process.exit(0)
155
+ })
156
+ break
157
+ }
158
+ case 'stop':
159
+ console.log('前台运行的 dsh-remote 用 Ctrl+C 停止;插件模式下请在 profile 设置里关闭该插件。')
160
+ break
161
+ default:
162
+ console.log(`未知命令:${command}`)
163
+ console.log('可用:status | connect | cookie | qr | doctor | start | stop')
164
+ process.exitCode = 1
165
+ }
166
+ }
167
+
168
+ main().catch((err) => {
169
+ console.error(err?.stack ?? String(err))
170
+ process.exitCode = 1
171
+ })
@@ -0,0 +1,21 @@
1
+ # Bundle patch layer for the dsh-remote plugin.
2
+ #
3
+ # One insert row mounts the Host half only: the plugin runs inside the DSH host process, starts
4
+ # the Host-rewriting bridge and the tunnel from there, and writes the resulting URL + cookie to
5
+ # ~/.dsh/dsh-remote/. There is no browser half — the phone talks to the bridge, not to the web UI.
6
+ #
7
+ # Every key below is overridable from a saved profile row:
8
+ # enabled false disables the whole plugin without uninstalling it
9
+ # port local bridge port the phone connects to on the LAN
10
+ # authority Host header the bridge presents; the signed cookie is bound to it
11
+ # tunnel "auto" (ngrok if found, else cloudflared), "off", "ngrok" or "cloudflared"
12
+ # upstreamPort pin the DSH host port; 0 means discover it automatically
13
+ - insert:
14
+ - id: dsh-remote
15
+ name: 'dsh-remote-hub'
16
+ config:
17
+ enabled: true
18
+ port: 6300
19
+ authority: '127.0.0.1:1815'
20
+ tunnel: 'auto'
21
+ upstreamPort: 0
package/lib/bridge.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Host-rewriting reverse proxy between the phone and the DSH host.
3
+ *
4
+ * Two invariants:
5
+ *
6
+ * 1. The cookie's audience is whatever Host DSH receives, so this proxy always presents a fixed
7
+ * authority (`127.0.0.1:1815` by default). One signed cookie therefore keeps working across
8
+ * host restarts and across every transport.
9
+ * 2. The upstream port moves, so forwarding never pins it: a failed request or a 5xx triggers a
10
+ * re-resolve before the error reaches the phone (up to one retry).
11
+ *
12
+ * It also trims `/api/session/list`, which is the single heaviest payload the app pulls (every
13
+ * Session with its full projection set). The phone only renders title / permissions /
14
+ * modelSelection, so projecting it here cuts the wire body by roughly 8x.
15
+ *
16
+ * @module lib/bridge
17
+ */
18
+
19
+ import http from 'node:http'
20
+ import net from 'node:net'
21
+ import zlib from 'node:zlib'
22
+
23
+ import { handlePairing } from './pairing-routes.js'
24
+
25
+ const LIST_PATH = '/api/session/list'
26
+
27
+ function rewriteHeaders(headers, authority) {
28
+ const next = { ...headers }
29
+ delete next.origin
30
+ delete next.referer
31
+ next.host = authority
32
+ return next
33
+ }
34
+
35
+ /** Keep only the fields the Android app reads, from a `session/list` response body. */
36
+ function trimSessionList(body) {
37
+ const doc = JSON.parse(body.toString('utf8'))
38
+ const items = doc?.result?.value?.items
39
+ if (!Array.isArray(items)) return null
40
+ doc.result.value.items = items.map((row) => {
41
+ const v = row?.projections?.values ?? row?.projections ?? {}
42
+ return {
43
+ sessionId: row.sessionId,
44
+ updatedAt: row.updatedAt,
45
+ running: row.running,
46
+ blank: row.blank,
47
+ origin: row.origin,
48
+ cwd: row.cwd,
49
+ projections: {
50
+ values: {
51
+ title: v.title,
52
+ permissions: v.permissions,
53
+ modelSelection: v.modelSelection,
54
+ },
55
+ },
56
+ }
57
+ })
58
+ return Buffer.from(JSON.stringify(doc), 'utf8')
59
+ }
60
+
61
+ /**
62
+ * Start the bridge.
63
+ *
64
+ * @param {object} options
65
+ * @param {number} options.listenPort
66
+ * @param {string} options.authority
67
+ * @param {{resolve: (force?: boolean) => Promise<number>, invalidate: () => void}} options.upstream
68
+ * @param {(msg: string) => void} [options.log]
69
+ * @returns {Promise<{close: () => Promise<void>, port: number}>}
70
+ */
71
+ export function startBridge({ listenPort, authority, upstream, pairing = null, log = () => {} }) {
72
+ const forward = (req, res, attempt = 0) =>
73
+ new Promise((resolve) => {
74
+ upstream.resolve(attempt > 0).then((port) => {
75
+ if (!port) {
76
+ res.writeHead(502)
77
+ res.end('dsh-remote: no upstream found (is the DSH host running?)')
78
+ return resolve()
79
+ }
80
+ const headers = rewriteHeaders(req.headers, authority)
81
+ // DSH gzips on its own when it sees Accept-Encoding; the list needs plain JSON so it can
82
+ // be trimmed and re-compressed here.
83
+ const isList = req.url === LIST_PATH
84
+ if (isList) delete headers['accept-encoding']
85
+
86
+ const up = http.request({ host: '127.0.0.1', port, path: req.url, method: req.method, headers }, (r) => {
87
+ if (r.statusCode >= 500 && attempt === 0) {
88
+ r.resume()
89
+ upstream.invalidate()
90
+ return forward(req, res, attempt + 1).then(resolve)
91
+ }
92
+ const contentType = String(r.headers['content-type'] || '')
93
+ const clientWantsGzip = /gzip/i.test(String(req.headers['accept-encoding'] || ''))
94
+ const canRewrite = isList && r.statusCode === 200 && !r.headers['content-encoding']
95
+ if (!canRewrite || !/json/i.test(contentType)) {
96
+ res.writeHead(r.statusCode, { ...r.headers, 'x-dsh-remote-bridge': '1' })
97
+ r.pipe(res)
98
+ r.on('end', resolve)
99
+ return
100
+ }
101
+ const chunks = []
102
+ r.on('data', (c) => chunks.push(c))
103
+ r.on('end', () => {
104
+ const headersOut = { ...r.headers }
105
+ let body = Buffer.concat(chunks)
106
+ try {
107
+ const trimmed = trimSessionList(body)
108
+ if (trimmed) {
109
+ body = trimmed
110
+ delete headersOut['content-length']
111
+ }
112
+ } catch {
113
+ /* unexpected shape: pass the original body through */
114
+ }
115
+ headersOut['x-dsh-remote-bridge'] = '1'
116
+ if (clientWantsGzip && body.length >= 2048) {
117
+ const gz = zlib.gzipSync(body, { level: 6 })
118
+ headersOut['content-encoding'] = 'gzip'
119
+ headersOut['content-length'] = String(gz.length)
120
+ delete headersOut['transfer-encoding']
121
+ res.writeHead(r.statusCode, headersOut)
122
+ res.end(gz)
123
+ } else {
124
+ res.writeHead(r.statusCode, headersOut)
125
+ res.end(body)
126
+ }
127
+ resolve()
128
+ })
129
+ r.on('error', () => {
130
+ try {
131
+ res.destroy()
132
+ } catch {}
133
+ resolve()
134
+ })
135
+ })
136
+ up.on('error', () => {
137
+ if (attempt === 0) {
138
+ upstream.invalidate()
139
+ return forward(req, res, attempt + 1).then(resolve)
140
+ }
141
+ try {
142
+ res.writeHead(502)
143
+ res.end('dsh-remote: upstream unavailable')
144
+ } catch {}
145
+ resolve()
146
+ })
147
+ req.pipe(up)
148
+ })
149
+ })
150
+
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
+ }
163
+ forward(req, res).catch(() => {
164
+ try {
165
+ res.writeHead(502)
166
+ res.end('dsh-remote: internal error')
167
+ } catch {}
168
+ })
169
+ })
170
+
171
+ // WebSocket upgrades (session/follow) need the same Host rewrite, done by hand: a raw socket
172
+ // tunnel cannot reuse `http.request`.
173
+ server.on('upgrade', (req, socket, head) => {
174
+ upstream.resolve().then((port) => {
175
+ if (!port) {
176
+ socket.destroy()
177
+ return
178
+ }
179
+ const up = net.connect(port, '127.0.0.1', () => {
180
+ const headers = rewriteHeaders({ ...req.headers, connection: 'Upgrade', upgrade: req.headers.upgrade }, authority)
181
+ const lines = [
182
+ `GET ${req.url} HTTP/1.1`,
183
+ ...Object.entries(headers).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`),
184
+ '',
185
+ '',
186
+ ]
187
+ up.write(lines.join('\r\n'))
188
+ if (head?.length) up.write(head)
189
+ up.pipe(socket)
190
+ socket.pipe(up)
191
+ })
192
+ up.on('error', () => socket.destroy())
193
+ socket.on('error', () => up.destroy())
194
+ })
195
+ })
196
+
197
+ return new Promise((resolve, reject) => {
198
+ server.once('error', reject)
199
+ server.listen(listenPort, '0.0.0.0', () => {
200
+ server.removeListener('error', reject)
201
+ log(`bridge listening on 0.0.0.0:${listenPort} (Host: ${authority})`)
202
+ resolve({
203
+ port: listenPort,
204
+ close: () =>
205
+ new Promise((done) => {
206
+ server.close(() => done())
207
+ // `close` waits for keep-alive sockets; drop them so shutdown is prompt.
208
+ server.closeAllConnections?.()
209
+ }),
210
+ })
211
+ })
212
+ })
213
+ }