dsh-mini-remote 0.1.0 → 0.1.3

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/lib/page.js CHANGED
@@ -44,3 +44,24 @@ export function readArt(name) {
44
44
  return null
45
45
  }
46
46
  }
47
+
48
+ const SW_PATH = join(HERE, 'sw.js')
49
+
50
+ let sw = null
51
+
52
+ /**
53
+ * 读 service worker 的源码,读不到返回 null。
54
+ *
55
+ * 和 page.html 一样,启动时读一次进内存。**改了它要重启 DSH**——理由和 page.html
56
+ * 完全相同:服务器手里那份是启动时读的,不重启就还是旧的。
57
+ */
58
+ export function readSw() {
59
+ if (sw === null) {
60
+ try {
61
+ sw = readFileSync(SW_PATH, 'utf8')
62
+ } catch {
63
+ sw = ''
64
+ }
65
+ }
66
+ return sw || null
67
+ }
package/lib/pairing.js CHANGED
@@ -6,13 +6,42 @@
6
6
  * 进,不用手敲密码。
7
7
  */
8
8
  import QRCode from 'qrcode'
9
- import { execFileSync } from 'node:child_process'
10
9
  import { reachableAddresses } from './net.js'
10
+ import { runTailscale } from './serve.js'
11
11
 
12
12
  const HINTS = {
13
13
  lan: '手机连同一个 Wi-Fi 时用这个',
14
14
  tailscale: '在外面(用手机流量)也能用',
15
- public: '不用装任何东西,出门在外也能用。刚打开时等半分钟再扫;地址每次重启 DSH 会换',
15
+ // 2026-09-25 补一句「它也是加密的」。原来只说了「不用装任何东西」,可**加密这件事
16
+ // 有两条来源,只有 Tailscale 那条依赖手机上的 MagicDNS**——公网隧道用的是
17
+ // Cloudflare 的域名,谁都能解析。所以手机上 Tailscale 的 DNS 设置没开的用户,
18
+ // 想要加密连接应该走这一条,而原来的说明里完全没告诉他这一点。
19
+ public: '不用装任何东西,出门在外也能用,而且它也是加密的——浏览器肯不肯给用通知和'
20
+ + '麦克风看的就是这个。刚打开时等半分钟再扫;地址每次重启 DSH 会换',
21
+ // 这条和上面那条 Tailscale 是**同一台电脑、同一个服务**,区别只在连接加不加密。
22
+ // 说清楚「加密能换来什么」——不然用户看不出两条码为什么要并存。
23
+ //
24
+ // 也要说清楚它的**代价**。2026-09-25 用户手机上真撞到了:`*.ts.net` 这个名字
25
+ // 在公网根本不存在(问 Cloudflare 回 NXDOMAIN,对照 tailscale.com 正常),
26
+ // 只在 tailnet 内部解析得出,靠的是客户端上的 MagicDNS。
27
+ //
28
+ // **而且有些客户端根本没有 MagicDNS。** 用户手机上装的是 HarmonyOS 的第三方
29
+ // 移植版 MeshArc(flypigJ/Tailscale-OHOS),它自己的说明里写着
30
+ // 「MagicDNS is not enabled; use Tailscale IP addresses」——那类手机上这条路
31
+ // 走不通,**不是设置没开的问题**。所以别写「去开手机上那个开关」:那个开关在
32
+ // 这些客户端上不存在,等于让用户去点一个没有的东西。(第一版就是这么写的,错。)
33
+ //
34
+ // 退路有两条,都是真的:
35
+ // 1. 上面那条明文的 Tailscale 用的是 IP,不需要任何解析,任何情况下都连得上;
36
+ // 2. 公网隧道那条**也是加密的**,用的是 Cloudflare 的域名,谁都能解析。
37
+ // 这是「几条码为什么并存」最实在的答案:它们不是互相替代,是各有各的前提。
38
+ // 2026-09-25 用户实测走通了:他手机上装的正是 MeshArc,**换成官方客户端之后
39
+ // 那条加密地址就打开了**。所以这一句要给出真正的解法——换客户端,而不是绕开。
40
+ // 原来只写了「用不了就换别的路」,那是在绕,不是在解决。
41
+ 'tailscale-https': '出门在外也能用,而且连接是加密的——手机浏览器只在加密连接上才肯给用'
42
+ + '通知和麦克风。手机上的 Tailscale 得能解析你这个网络的域名:HarmonyOS 上的第三方'
43
+ + '客户端(MeshArc)不支持这一点,换成官方客户端就能用。实在换不了,就退到上面那条'
44
+ + '明文的(它用 IP,不需要解析),或者开公网隧道那条(它也是加密的,不挑手机)',
16
45
  }
17
46
 
18
47
  /** 官方下载页。给没装的人一个明确的下一步。 */
@@ -29,14 +58,17 @@ export const TAILSCALE_DOWNLOAD = 'https://tailscale.com/download'
29
58
  * 就当作「装了但状态不明」,界面上退回一句两种成因都覆盖的话——宁可说得含糊,
30
59
  * 也不能让人白等,或者叫人去装一个他已经装了的软件。
31
60
  */
32
- function tailscaleCliPresent() {
33
- try {
34
- execFileSync('tailscale', ['version'], { timeout: 1500, stdio: 'ignore' })
35
- return true
36
- } catch (err) {
37
- // ENOENT = 系统里根本没有这个命令;超时或别的错 = 装了,只是问不出来。
38
- return err?.code !== 'ENOENT'
39
- }
61
+ async function tailscaleCliPresent() {
62
+ // 用 serve.js 里那一个,不另起一套:它按「环境变量 → PATH → 常见安装位置」挨个试,
63
+ // 而且是**异步**的。
64
+ //
65
+ // 原来这里是 `execFileSync('tailscale', ['version'], …)`,两个毛病:
66
+ // 1. 命令名写死、靠 PATH。别人装在别处时它回 ENOENT,界面就会说「这台电脑上
67
+ // 没装 Tailscale」——而其实装着呢,这是最误导人的一种说法;
68
+ // 2. 同步。最坏要卡住 1.5 秒,而这个进程同时在给手机推实时消息——
69
+ // 手机上表现为「突然不动了」。
70
+ const r = await runTailscale(['version'], 1500)
71
+ return !r.missing
40
72
  }
41
73
 
42
74
  /**
@@ -100,8 +132,9 @@ async function qr(text) {
100
132
  * @param {string} opts.token
101
133
  * @param {string[]} [opts.bound] 真正绑定成功的地址;不传则用探测结果
102
134
  * @param {{enabled?: boolean, url?: string|null, starting?: boolean, error?: string|null}} [opts.tunnel] 公网隧道状态
135
+ * @param {{installed?: boolean, url?: string|null, urlOfOtherPort?: string|null, error?: string|null}} [opts.serve] Tailscale HTTPS 状态
103
136
  */
104
- export async function buildPairing({ port, token, bound, tunnel }) {
137
+ export async function buildPairing({ port, token, bound, tunnel, serve }) {
105
138
  const detected = reachableAddresses()
106
139
  const usable = bound ? detected.filter((a) => bound.includes(a.address)) : detected
107
140
 
@@ -117,6 +150,30 @@ export async function buildPairing({ port, token, bound, tunnel }) {
117
150
  })
118
151
  }
119
152
 
153
+ /**
154
+ * Tailscale 的 HTTPS 那条,紧跟在 Tailscale 那条后面。
155
+ *
156
+ * 为什么是**并存**而不是把上面那条换掉:2026-09-21 用户裁决过「几条路并存、
157
+ * 由用户自己选,不许拿一条替换另一条」。这里虽然是同一个地址的两种形态,
158
+ * 但道理一样——明文那条在 HTTPS 出问题(证书过期、Tailscale 改版)时是退路。
159
+ *
160
+ * 摆在一起是有意的:用户要能一眼看出这俩是同一台电脑,区别只在加不加密。
161
+ */
162
+ if (serve?.url) {
163
+ const base = String(serve.url).replace(/\/+$/, '')
164
+ const url = `${base}/mini?token=${token}`
165
+ const entry = {
166
+ kind: 'tailscale-https',
167
+ label: 'Tailscale(加密)',
168
+ hint: HINTS['tailscale-https'],
169
+ url,
170
+ qr: await qr(url),
171
+ }
172
+ const at = entries.findIndex((e) => e.kind === 'tailscale')
173
+ if (at >= 0) entries.splice(at + 1, 0, entry)
174
+ else entries.push(entry)
175
+ }
176
+
120
177
  // 公网那条排在最后:它是补充,不是主路
121
178
  if (tunnel?.url) {
122
179
  const url = `${tunnel.url}/mini?token=${token}`
@@ -136,6 +193,36 @@ export async function buildPairing({ port, token, bound, tunnel }) {
136
193
  error: tunnel?.error ?? null,
137
194
  }
138
195
 
196
+ /**
197
+ * Tailscale HTTPS 的现状,**四种返回路径都要带上**。
198
+ *
199
+ * 第 26 轮吃过一次亏:`tailscale` 那个字段只在成功路径上带,失败的时候那一行
200
+ * 又会整个消失,用户看到的是「这里什么都没有」,不知道自己缺了什么。这次
201
+ * 从一开始就带上,下面三处早退和最后一处成功全都有它。
202
+ *
203
+ * `urlOfOtherPort` 要单独留着:serve 完全可能被用户拿去转发别的东西,
204
+ * 那时候不能含糊地说一句「没开」——得说清楚「配着,但指的是别的端口」,
205
+ * 否则用户会以为是自己这台电脑不支持。
206
+ */
207
+ const httpsStatus = {
208
+ installed: serve?.installed !== false,
209
+ on: Boolean(serve?.url),
210
+ url: serve?.url ?? null,
211
+ urlOfOtherPort: serve?.urlOfOtherPort ?? null,
212
+ error: serve?.error ?? null,
213
+ /**
214
+ * 下面这几个是给**还没走完这一关的人**准备的。
215
+ *
216
+ * 走到这一步之前,面板上只有一句「没开」——用户知道要开,但不知道去哪儿开。
217
+ * 而 Tailscale 自己把该去的地方印在报错里了(开启链接、登录链接),
218
+ * 插件要做的事只有一件:把它原样递到用户手边,别让他自己去翻后台。
219
+ */
220
+ enableLink: serve?.enableLink ?? null,
221
+ needsLogin: Boolean(serve?.needsLogin),
222
+ loginUrl: serve?.loginUrl ?? null,
223
+ backendState: serve?.backendState ?? null,
224
+ }
225
+
139
226
  /**
140
227
  * 没探到 Tailscale 地址时,也要给界面一句话。
141
228
  *
@@ -143,13 +230,13 @@ export async function buildPairing({ port, token, bound, tunnel }) {
143
230
  * 「这里什么都没有」。他不知道自己缺了什么,也就不会想到去装。而他恰恰是
144
231
  * 最需要这条路的人:不装 Tailscale,手机出门在外就只剩公网隧道那条路。
145
232
  */
146
- const tailscale = tailscaleNotice(detected, tailscaleCliPresent())
233
+ const tailscale = tailscaleNotice(detected, await tailscaleCliPresent())
147
234
 
148
235
  if (!entries.length) {
149
236
  // 一条能用的都没有。三种成因要分开说,否则用户不知道该去改配置、去查网络,
150
237
  // 还是去查隧道——这三件事的下一步动作完全不同。
151
238
  if (status.enabled && status.error) {
152
- return { ok: false, error: `公网访问没起来:${status.error}`, tunnel: status, tailscale }
239
+ return { ok: false, error: `公网访问没起来:${status.error}`, tunnel: status, tailscale, serve: httpsStatus }
153
240
  }
154
241
  if (detected.length) {
155
242
  return {
@@ -158,6 +245,7 @@ export async function buildPairing({ port, token, bound, tunnel }) {
158
245
  + '重启 DSH 之后手机就能连了。',
159
246
  tunnel: status,
160
247
  tailscale,
248
+ serve: httpsStatus,
161
249
  }
162
250
  }
163
251
  return {
@@ -165,8 +253,9 @@ export async function buildPairing({ port, token, bound, tunnel }) {
165
253
  error: '没找到手机能连上的地址。检查一下这台电脑的 Wi-Fi/网线,或者 Tailscale 是否登录。',
166
254
  tunnel: status,
167
255
  tailscale,
256
+ serve: httpsStatus,
168
257
  }
169
258
  }
170
259
 
171
- return { ok: true, port, token, entries, tunnel: status, tailscale }
260
+ return { ok: true, port, token, entries, tunnel: status, tailscale, serve: httpsStatus }
172
261
  }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 手机端切换权限档位。
3
+ *
4
+ * ## 为什么做这个
5
+ *
6
+ * 在它之前,README 里有一条硬要求:「要在移动端使用该插件,请将 PC 端 DSH 会话
7
+ * 页面切换到【完全权限】」——因为审批弹窗长在电脑屏幕上,手机上点不着。
8
+ *
9
+ * 那条要求等于**逼着用户把电脑永久钉在最高权限上**。这是个很糟的默认值:
10
+ * 他一天里可能只有十分钟需要 Agent 动文件,剩下时间电脑都敞着。
11
+ *
12
+ * 有了这个之后,电脑可以长期停在「仅可查看」,需要的时候从手机上抬上去。
13
+ *
14
+ * ## 代价(必须说清楚,不许粉饰)
15
+ *
16
+ * **手机从此能指挥 Agent 把电脑提到完全权限。** 也就是说,PC 端那道授权闸门
17
+ * 被搬到了手机上——闸门还在(要 token / 密码),但它守的那扇门换了一扇。
18
+ * 拿到手机的人,等于拿到了这台电脑的写权限。这是功能本身的代价,不是实现瑕疵。
19
+ *
20
+ * ## 和 DSH 的关系
21
+ *
22
+ * 我们不自己管权限,只是把 DSH 的 `PermissionPresetService` 接到手机上。
23
+ * 它按会话生效(`set(session, name)`),所以作用域天然就是「当前绑定的那个会话」。
24
+ *
25
+ * 档位名和标签**一律运行时读**(`names` / `optionOf`),一个都不写死:
26
+ * 这台机器的配置里到底两个档位还是三个、中文叫什么,只有跑起来才知道。
27
+ */
28
+
29
+ /** 档位表里的保留名,代表「当前旋钮值不匹配任何档位」。它不是一个切换目标。 */
30
+ export const CUSTOM_PRESET = 'custom'
31
+
32
+ /**
33
+ * 沙箱模式里最放开的那一档。
34
+ *
35
+ * 判断「哪个档位要额外确认」用的是**这个协议常量**,不是档位名。
36
+ * 档位名是这台机器的配置(可能是 `danger-full-access`,也可能被人改叫别的),
37
+ * 猜名字一定会错;而 `sandbox` 这个旋钮的取值是 DSH 自己的协议,稳定。
38
+ */
39
+ export const FULL_ACCESS_MODE = 'danger-full-access'
40
+
41
+ /**
42
+ * 档位标识符 → 中文标签。
43
+ *
44
+ * ## 为什么这张表在我们这儿,而不在服务里
45
+ *
46
+ * `PermissionPresetService.optionOf()` 只在**配置里定义过 `name`** 时才给标签。
47
+ * 本机没定义,于是它退回了键名——手机上就会显示 `read-only` /
48
+ * `workspace-write` / `danger-full-access` 三串英文。
49
+ *
50
+ * 那么三个中文(仅可查看 / 工作区内修改 / 完全权限)在哪?在 **DSH 自己的网页
51
+ * 界面**里,是客户端那侧的翻译,服务里没有。所以只能我们这一侧补一份。
52
+ *
53
+ * 标识符本身是稳定的(它们是 DSH 的档位键名,不是这台机器的自定义名字),
54
+ * 可以放心当键。真正会变的是**配置里给的自定义标签**——见 labelOf。
55
+ */
56
+ export const PRESET_LABELS = {
57
+ 'read-only': '仅可查看',
58
+ 'workspace-write': '工作区内修改',
59
+ 'danger-full-access': '完全权限',
60
+ }
61
+
62
+ /**
63
+ * 决定一个档位显示成什么。
64
+ *
65
+ * 优先级:
66
+ * 1. 配置里明确给了标签(且跟键名不一样)——**尊重这台机器主人的命名**。
67
+ * 他在配置里写了「只读模式」,我们不该拿一张通用表把它盖回去。
68
+ * 2. 我们的映射表。
69
+ * 3. 原始标识符。
70
+ *
71
+ * 第 3 条是给未来留的:DSH 哪天加了个新档位,界面上会显示它的标识符——
72
+ * 不好看,但那是个**真名字**,不至于变成空白或者「未知」。
73
+ */
74
+ export function labelOf(value, configured) {
75
+ if (typeof configured === 'string' && configured && configured !== value) return configured
76
+ return PRESET_LABELS[value] ?? value
77
+ }
78
+
79
+ /** 服务是不是真的能用。拿不到(headless 组合里就没有)时整块不显示。 */
80
+ export function available(service) {
81
+ return Boolean(service) && Array.isArray(service.names)
82
+ }
83
+
84
+ /** 这个档位是不是「完全权限」——切到它要一次显式确认。 */
85
+ function dangerousOf(service, name) {
86
+ try {
87
+ return service.resolve(name)?.sandbox === FULL_ACCESS_MODE
88
+ } catch (err) {
89
+ // resolve 对表外的名字会抛。表里列了却 resolve 不了,当作不危险:
90
+ // 宁可少问一次,也不要因为一个读不到的值把整个界面卡死。
91
+ return false
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 当前会话的档位盘:有哪些档位、现在在哪一档。
97
+ *
98
+ * 返回 `{ ok:false, reason:'no-service' }` 时,调用方应当**整块不显示**,
99
+ * 遥控本身照常——它是个附加能力,缺了不该影响发指令。
100
+ */
101
+ export function listPresets({ service, session }) {
102
+ if (!available(service)) return { ok: false, reason: 'no-service' }
103
+ if (!session) return { ok: false, reason: 'no-session' }
104
+
105
+ const options = []
106
+ for (const name of service.names) {
107
+ try {
108
+ const option = service.optionOf(name)
109
+ options.push({
110
+ value: option.value,
111
+ // 服务给的是标识符(本机没配中文),中文在这一侧映射;
112
+ // 映射不到就用原始标识符,不留空。
113
+ name: labelOf(name, option.name),
114
+ description: option.description ?? '',
115
+ dangerous: dangerousOf(service, name),
116
+ })
117
+ } catch (err) {
118
+ // 表里列了这个档位、optionOf 却不认——跳过这一个,别把整盘弄没。
119
+ }
120
+ }
121
+
122
+ let currentValue = ''
123
+ try {
124
+ currentValue = service.current(session)
125
+ } catch (err) {
126
+ currentValue = ''
127
+ }
128
+ // `custom` 只在它真的生效时补一格,用来**显示**(不匹配任何档位,
129
+ // 比如有人在电脑上手动调过旋钮)。它永远不是切换目标。
130
+ if (currentValue === CUSTOM_PRESET && !options.some((o) => o.value === CUSTOM_PRESET)) {
131
+ try {
132
+ const option = service.optionOf(CUSTOM_PRESET)
133
+ options.push({
134
+ value: option.value,
135
+ name: labelOf(CUSTOM_PRESET, option.name),
136
+ description: option.description ?? '',
137
+ dangerous: false,
138
+ })
139
+ } catch (err) { /* 拿不到就不要这一格,反正 currentValue 会显示出来 */ }
140
+ }
141
+
142
+ return { ok: true, options, currentValue }
143
+ }
144
+
145
+ /**
146
+ * 换一档。
147
+ *
148
+ * **手机传来的档位名当不可信输入**:只接受 `names` 里真实存在的那几个,
149
+ * 绝不直接透传给 `set()`。`set()` 对未知名字是**抛错**,不是默默忽略——
150
+ * 透传的话,一个拼错的名字会变成一条 500,而用户看到的是一句看不懂的话。
151
+ */
152
+ export function setPreset({ service, session, name }) {
153
+ if (!available(service)) return { ok: false, reason: 'no-service' }
154
+ if (!session) return { ok: false, reason: 'no-session' }
155
+ if (typeof name !== 'string' || !service.names.includes(name)) {
156
+ return { ok: false, reason: 'unknown-preset' }
157
+ }
158
+ try {
159
+ // set() 是同步的:它记一条 preset 事件,再把变了的旋钮各自写一遍。
160
+ service.set(session, name)
161
+ } catch (err) {
162
+ return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
163
+ }
164
+ return { ok: true, name }
165
+ }