dsh-mini-remote 0.1.0 → 0.1.4
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/CHANGELOG.md +82 -0
- package/README.en.md +160 -0
- package/README.md +81 -59
- package/client/client.js +148 -1
- package/lib/art/work-9-talking.webp +0 -0
- package/lib/browse.js +216 -0
- package/lib/commands.js +166 -0
- package/lib/events.js +52 -0
- package/lib/history.js +72 -0
- package/lib/index.js +801 -12
- package/lib/log-tail.js +235 -0
- package/lib/page.html +1699 -36
- package/lib/page.js +21 -0
- package/lib/pairing.js +110 -14
- package/lib/permissions.js +165 -0
- package/lib/serve.js +365 -0
- package/lib/server.js +465 -6
- package/lib/store.js +191 -8
- package/lib/sw.js +39 -0
- package/lib/tree.js +145 -3
- package/package.json +15 -6
- package/README.zh.md +0 -138
- package/client/verify.mjs +0 -514
package/lib/serve.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用 Tailscale 自带的 HTTPS 地址,把本机服务暴露给 tailnet。
|
|
3
|
+
*
|
|
4
|
+
* 为什么值得做:浏览器只有在安全上下文(https)下才肯把完整能力交出来,明文 http
|
|
5
|
+
* 一律打折——这正是当初做这条路的理由。Tailscale 的 serve 给的是 Let's Encrypt 签的
|
|
6
|
+
* 真证书,浏览器认。
|
|
7
|
+
*
|
|
8
|
+
* **2026-09-27 修正这段自述**:原来这里写的是"三样东西被明文 http 挡着——完成后提醒
|
|
9
|
+
* (系统通知)、语音输入(麦克风)、剪贴板的完整能力"。后来其中两样**都没有真正落地**:
|
|
10
|
+
* 系统通知 2026-09-25 起入口一直藏着(锁屏会冻结后台页面,试到底的结论是这条路走不通),
|
|
11
|
+
* 麦克风图标也已移除,两条都有测试钉着"别再被加回来"。如今真正成立的只有"连接加密"
|
|
12
|
+
* 这一条(剪贴板另有 execCommand 退路,明文下也能用,只是不那么顺)。所以对用户说话时
|
|
13
|
+
* 只讲加密,**别再拿通知和麦克风当理由**(见 lib/pairing.js 里那两条提示的注释)。
|
|
14
|
+
*
|
|
15
|
+
* **这不是新的一条路,是 Tailscale 那条路上的一个升级。** 原来的
|
|
16
|
+
* `http://100.92.105.99:3090` 照旧能用,这里多给一个
|
|
17
|
+
* `https://<机器名>.<tailnet>.ts.net/`——而且不用写端口,更好记。
|
|
18
|
+
*
|
|
19
|
+
* 2026-09-24 本机实测:证书 Let's Encrypt 签、域名精确匹配、
|
|
20
|
+
* 稳态往返 18 毫秒(第一次 30 秒是在签发证书)。
|
|
21
|
+
*
|
|
22
|
+
* **一件事必须先说清楚**:tailnet 上得先开 Serve 这个功能,否则命令会失败。
|
|
23
|
+
* 那是个一次性开关,只有 tailnet 管理员能开——但 Tailscale 把开启链接直接印在
|
|
24
|
+
* 报错里了,所以这里的做法是**把那条链接原样交给用户**,而不是自己写一句
|
|
25
|
+
* 「请到后台开启」。见 enableLinkFrom()。
|
|
26
|
+
*/
|
|
27
|
+
import { execFile } from 'node:child_process'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 去哪儿找 tailscale 这个命令。
|
|
32
|
+
*
|
|
33
|
+
* 原来写死成 `'tailscale'`、靠 PATH。这台电脑上 PATH 里有,所以一直没出问题——
|
|
34
|
+
* 但这个插件不是给一个人用的,别人完全可能装在别处,或者 PATH 里没有。
|
|
35
|
+
* 那对他来说就是「插件说找不到 Tailscale」,而其实装着呢。
|
|
36
|
+
*
|
|
37
|
+
* 按顺序试:先认环境变量给的(也是测试用的口子),再认 PATH,最后认几个常见位置。
|
|
38
|
+
* 挨个试不费时间——不存在的那几个立刻回 ENOENT。
|
|
39
|
+
*/
|
|
40
|
+
export function cliCandidates() {
|
|
41
|
+
const out = []
|
|
42
|
+
const override = process.env.DSH_MINI_REMOTE_TAILSCALE
|
|
43
|
+
if (override) out.push(override)
|
|
44
|
+
out.push('tailscale')
|
|
45
|
+
if (process.platform === 'win32') {
|
|
46
|
+
out.push(join(process.env.ProgramFiles || 'C:\\Program Files', 'Tailscale', 'tailscale.exe'))
|
|
47
|
+
out.push(join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Tailscale', 'tailscale.exe'))
|
|
48
|
+
} else if (process.platform === 'darwin') {
|
|
49
|
+
// Mac 的 GUI 版把命令行工具放在 app 包里,不一定进 PATH
|
|
50
|
+
out.push('/Applications/Tailscale.app/Contents/MacOS/Tailscale')
|
|
51
|
+
} else {
|
|
52
|
+
out.push('/usr/bin/tailscale', '/usr/local/bin/tailscale')
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 问现状给 2 秒就够——`serve status` 是查本地配置,正常几十毫秒。
|
|
59
|
+
*/
|
|
60
|
+
const STATUS_TIMEOUT_MS = 2000
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 开/关给 15 秒。这是**兜底**,正常走不到——见下面 runTailscale() 的 stopWhen。
|
|
64
|
+
*
|
|
65
|
+
* 为什么不是两三秒:tailnet 没开 Serve 的时候,这个命令**打印完提示还会继续挂着**
|
|
66
|
+
* (2026-09-24 实测挂了 180 秒没退)。打印很快,挂着的是后面那一步。给太短会把
|
|
67
|
+
* 已经拿到手的提示一起丢掉,用户就看不到那条开启链接了。
|
|
68
|
+
*/
|
|
69
|
+
const ACTION_TIMEOUT_MS = 15000
|
|
70
|
+
|
|
71
|
+
/** `Web` 里的键长这样:`<机器名>.<tailnet>.ts.net:443`。 */
|
|
72
|
+
const HOST_KEY_RE = /^([a-z0-9.-]+\.ts\.net):(\d+)$/i
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 跑一次 tailscale,成功失败都把输出带回来。
|
|
76
|
+
*
|
|
77
|
+
* **异步,不用 execFileSync。** 这个进程同时还在给手机推实时消息,同步版会把整个
|
|
78
|
+
* 事件循环卡住——最长能卡 15 秒,手机上表现为「突然不动了」。查配置那一下虽然快,
|
|
79
|
+
* 但快是常态、不是保证,所以两条路都用异步。
|
|
80
|
+
*
|
|
81
|
+
* **失败时 stdout 和 stderr 都要留**:「没开 Serve」那条关键提示印在 stderr 上,
|
|
82
|
+
* 而正常的 serve status JSON 印在 stdout 上,两边都不能丢。
|
|
83
|
+
*
|
|
84
|
+
* **边跑边看(stopWhen)。** 「没开 Serve」那种情况,命令会先把提示打印出来、
|
|
85
|
+
* 然后一直挂着不退出。只等回调的话,用户点一下开关要干等 15 秒才看到那条链接——
|
|
86
|
+
* 而他明明一秒前就能看到。给了 stopWhen,就在输出里认出目标的那一刻收工、把它杀掉。
|
|
87
|
+
* `stopped: true` 表示「是我叫停的,不是它自己跑完的」——**调用方必须当失败处理**,
|
|
88
|
+
* 别当成开成功了。
|
|
89
|
+
*/
|
|
90
|
+
export function runTailscale(args, timeout, { stopWhen } = {}) {
|
|
91
|
+
const bins = cliCandidates()
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
let settled = false
|
|
94
|
+
let acc = ''
|
|
95
|
+
const finish = (ok, missing, stopped) => {
|
|
96
|
+
if (settled) return
|
|
97
|
+
settled = true
|
|
98
|
+
resolve({ ok, out: acc, missing: Boolean(missing), stopped: Boolean(stopped) })
|
|
99
|
+
}
|
|
100
|
+
const attempt = (i) => {
|
|
101
|
+
if (settled) return
|
|
102
|
+
if (i >= bins.length) {
|
|
103
|
+
finish(false, true)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
let child
|
|
107
|
+
try {
|
|
108
|
+
child = execFile(bins[i], args, { timeout, encoding: 'utf8', windowsHide: true })
|
|
109
|
+
} catch {
|
|
110
|
+
attempt(i + 1)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
let sawData = false
|
|
114
|
+
const onData = (d) => {
|
|
115
|
+
sawData = true
|
|
116
|
+
acc += d
|
|
117
|
+
if (stopWhen && stopWhen(acc)) {
|
|
118
|
+
try {
|
|
119
|
+
child.kill()
|
|
120
|
+
} catch {
|
|
121
|
+
/* 已经退了就无所谓 */
|
|
122
|
+
}
|
|
123
|
+
finish(true, false, true)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
child.stdout?.on('data', onData)
|
|
127
|
+
child.stderr?.on('data', onData)
|
|
128
|
+
child.on('error', (err) => {
|
|
129
|
+
// 这个路径下没这个命令 → 试下一个。已经开始说话的就不换了,那是真出错。
|
|
130
|
+
if (err?.code === 'ENOENT' && !sawData) {
|
|
131
|
+
acc = ''
|
|
132
|
+
attempt(i + 1)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
finish(false, err?.code === 'ENOENT')
|
|
136
|
+
})
|
|
137
|
+
child.on('close', (code) => finish(code === 0, false))
|
|
138
|
+
}
|
|
139
|
+
attempt(0)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* tailnet 没开 Serve 时,Tailscale 会在报错里直接印出开启链接。把它抠出来。
|
|
145
|
+
*
|
|
146
|
+
* 为什么值得单独做一个函数:这是整个功能里**门槛最高的一步**——要去 tailnet
|
|
147
|
+
* 后台开一个开关。而官方已经把它压缩成「点一下这个链接」了。我们自己写一句
|
|
148
|
+
* 「请到后台开启 Serve 功能」等于把门槛又加回去,还指错了路。
|
|
149
|
+
*
|
|
150
|
+
* 实测原文:
|
|
151
|
+
* Serve is not enabled on your tailnet.
|
|
152
|
+
* To enable, visit:
|
|
153
|
+
* https://login.tailscale.com/f/serve?node=nKZqMyw1VE11CNTRL
|
|
154
|
+
*/
|
|
155
|
+
export function enableLinkFrom(text) {
|
|
156
|
+
const m = /https:\/\/login\.tailscale\.com\/f\/serve\?node=[A-Za-z0-9]+/.exec(String(text ?? ''))
|
|
157
|
+
return m ? m[0] : null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 从 `serve status --json` 里读出:配了没有、配的是不是我们想要的那个端口。
|
|
162
|
+
*
|
|
163
|
+
* 用 JSON 不用文本:文本那版是印给人看的,格式随版本变;JSON 是契约。
|
|
164
|
+
* 1.102.3 实测的形状:
|
|
165
|
+
*
|
|
166
|
+
* { "TCP": { "443": { "HTTPS": true } },
|
|
167
|
+
* "Web": { "desktop-gbsdc68.tail0429e3.ts.net:443":
|
|
168
|
+
* { "Handlers": { "/": { "Proxy": "http://127.0.0.1:3090" } } } } }
|
|
169
|
+
*
|
|
170
|
+
* 没配的时候是 `{}`。
|
|
171
|
+
*
|
|
172
|
+
* **为什么要比对端口**:serve 可以指向任何一个本地服务。用户完全可能拿它转发
|
|
173
|
+
* 别的东西(他自己的网站、别的工具)。指向别人的时候我们不能说「HTTPS 已开启」,
|
|
174
|
+
* 那会把一个扫不开的地址摆到手机上。
|
|
175
|
+
*
|
|
176
|
+
* @param {string} json `serve status --json` 的输出
|
|
177
|
+
* @param {number} port 我们的服务在听的端口
|
|
178
|
+
* @returns {{url: string|null, proxyPort: number|null, servingUs: boolean}}
|
|
179
|
+
*/
|
|
180
|
+
export function readServeStatus(json, port) {
|
|
181
|
+
const none = { url: null, proxyPort: null, servingUs: false }
|
|
182
|
+
let data
|
|
183
|
+
try {
|
|
184
|
+
data = JSON.parse(String(json ?? ''))
|
|
185
|
+
} catch {
|
|
186
|
+
return none
|
|
187
|
+
}
|
|
188
|
+
const web = data?.Web
|
|
189
|
+
if (!web || typeof web !== 'object') return none
|
|
190
|
+
for (const [hostPort, cfg] of Object.entries(web)) {
|
|
191
|
+
const m = HOST_KEY_RE.exec(hostPort)
|
|
192
|
+
if (!m) continue
|
|
193
|
+
const proxy = String(cfg?.Handlers?.['/']?.Proxy ?? '')
|
|
194
|
+
const pm = /:(\d+)\/?$/.exec(proxy)
|
|
195
|
+
const proxyPort = pm ? Number(pm[1]) : null
|
|
196
|
+
return {
|
|
197
|
+
url: `https://${m[1]}/`,
|
|
198
|
+
proxyPort,
|
|
199
|
+
// 端口对得上才算「在给我们服务」。对不上时 url 仍然带回来,
|
|
200
|
+
// 好让界面说清楚「serve 配着,但指的是别的端口」,而不是含糊地说「没开」。
|
|
201
|
+
servingUs: proxyPort === port,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return none
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 问一次现状。界面每次刷新配对信息都会调它。
|
|
209
|
+
*
|
|
210
|
+
* 装都没装的时候直接返回 `installed: false`,不去跑 serve status——
|
|
211
|
+
* 那只会多花一次超时。
|
|
212
|
+
*/
|
|
213
|
+
/**
|
|
214
|
+
* 开 serve 失败时,这到底是哪种失败。
|
|
215
|
+
*
|
|
216
|
+
* 抽成纯函数是为了能单测:真那条路要起子进程、要装 Tailscale、还依赖 tailnet 的
|
|
217
|
+
* 后台设置,测起来又慢又不可复现。而**这一支恰恰是整个功能里最要紧的一支**——
|
|
218
|
+
* 它是「用户点了一下开关,结果发现要去后台开个东西」那条路,处理得对不对,
|
|
219
|
+
* 决定了这个功能对非技术用户是能用还是不能用。
|
|
220
|
+
*
|
|
221
|
+
* 三种成因的下一步动作完全不同,所以必须分开:
|
|
222
|
+
* · `tailnet` → 点那条链接(Tailscale 官方印出来的开启链接)
|
|
223
|
+
* · `missing` → 去装 Tailscale
|
|
224
|
+
* · `other` → 把原始输出截一段给用户,别吞
|
|
225
|
+
*
|
|
226
|
+
* @param {string} out 命令的 stdout + stderr
|
|
227
|
+
* @param {boolean} missing 是不是 ENOENT(根本没这个命令)
|
|
228
|
+
*/
|
|
229
|
+
export function classifyEnableFailure(out, missing) {
|
|
230
|
+
const link = enableLinkFrom(out)
|
|
231
|
+
if (link) {
|
|
232
|
+
return {
|
|
233
|
+
reason: 'tailnet',
|
|
234
|
+
error: 'Tailscale 的 Serve 功能还没在这个账号上打开。点下面这条链接开一下,'
|
|
235
|
+
+ '然后回来再试一次——这是个一次性的开关,开过就不用再开了。',
|
|
236
|
+
enableLink: link,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (missing) {
|
|
240
|
+
return {
|
|
241
|
+
reason: 'missing',
|
|
242
|
+
error: '这台电脑上没装 Tailscale,所以拿不到 HTTPS 地址。',
|
|
243
|
+
enableLink: null,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
reason: 'other',
|
|
248
|
+
error: `开 HTTPS 地址失败了。Tailscale 说:${String(out ?? '').trim().slice(0, 300) || '(没有输出)'}`,
|
|
249
|
+
enableLink: null,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 从 `tailscale status --json` 里读后端状态。
|
|
255
|
+
*
|
|
256
|
+
* 为什么需要它:`serve status` 失败的时候,只说得出「问不出来」,可成因差别很大——
|
|
257
|
+
* **没登录**的人下一步是去登录,**没开 Serve** 的人下一步是去点开启链接,
|
|
258
|
+
* 而这两件事都发生在浏览器里、都得用户自己动手。笼统说一句「没开」,用户会去干错的事。
|
|
259
|
+
*
|
|
260
|
+
* `AuthURL` 只在需要登录时才由 Tailscale 给出,正好可以原样递给用户——
|
|
261
|
+
* 和那条开启链接是同一个套路。
|
|
262
|
+
*/
|
|
263
|
+
export function readBackendState(text) {
|
|
264
|
+
const none = { state: null, authUrl: null }
|
|
265
|
+
let d
|
|
266
|
+
try {
|
|
267
|
+
d = JSON.parse(text)
|
|
268
|
+
} catch {
|
|
269
|
+
return none
|
|
270
|
+
}
|
|
271
|
+
if (!d || typeof d !== 'object') return none
|
|
272
|
+
return {
|
|
273
|
+
state: typeof d.BackendState === 'string' ? d.BackendState : null,
|
|
274
|
+
// 只认 https 的,别把一个奇怪的串塞进 href 里
|
|
275
|
+
authUrl: typeof d.AuthURL === 'string' && /^https:\/\//.test(d.AuthURL) ? d.AuthURL : null,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function serveState(port) {
|
|
280
|
+
// 这里也挂 stopWhen:tailnet 没开 Serve 时,`serve status` 很可能和 `--bg` 一样
|
|
281
|
+
// 「打印完提示就挂着」。那样的话,面板一加载就能拿到那条开启链接——用户连开关
|
|
282
|
+
// 都不用点,一眼就看到该去哪儿开。
|
|
283
|
+
const r = await runTailscale(['serve', 'status', '--json'], STATUS_TIMEOUT_MS, {
|
|
284
|
+
stopWhen: (out) => Boolean(enableLinkFrom(out)),
|
|
285
|
+
})
|
|
286
|
+
if (!r.ok) {
|
|
287
|
+
const enableLink = enableLinkFrom(r.out)
|
|
288
|
+
// 问不出来的时候再问一次后端状态。没装的话不用问——`missing` 已经说清楚了。
|
|
289
|
+
let backend = { state: null, authUrl: null }
|
|
290
|
+
if (!r.missing) {
|
|
291
|
+
const st = await runTailscale(['status', '--json'], STATUS_TIMEOUT_MS)
|
|
292
|
+
if (st.ok) backend = readBackendState(st.out)
|
|
293
|
+
}
|
|
294
|
+
const needsLogin = backend.state === 'NeedsLogin' || backend.state === 'NeedsMachineAuth'
|
|
295
|
+
return {
|
|
296
|
+
installed: !r.missing,
|
|
297
|
+
configured: false,
|
|
298
|
+
servingUs: false,
|
|
299
|
+
url: null,
|
|
300
|
+
urlOfOtherPort: null,
|
|
301
|
+
backendState: backend.state,
|
|
302
|
+
needsLogin,
|
|
303
|
+
/** 需要登录时 Tailscale 给的登录链接,原样递给用户。 */
|
|
304
|
+
loginUrl: needsLogin ? backend.authUrl : null,
|
|
305
|
+
error: r.missing
|
|
306
|
+
? null
|
|
307
|
+
: needsLogin
|
|
308
|
+
? 'Tailscale 装了,但这个账号还没登录。'
|
|
309
|
+
: '问不出来 Tailscale serve 的现状。',
|
|
310
|
+
enableLink,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const s = readServeStatus(r.out, port)
|
|
314
|
+
return {
|
|
315
|
+
installed: true,
|
|
316
|
+
configured: Boolean(s.url),
|
|
317
|
+
servingUs: s.servingUs,
|
|
318
|
+
url: s.servingUs ? s.url : null,
|
|
319
|
+
/** serve 配着、但指的是别的端口。界面要说清楚,不能含糊成「没开」。 */
|
|
320
|
+
urlOfOtherPort: s.url && !s.servingUs ? s.url : null,
|
|
321
|
+
// 下面这几个成功路径上也要带,形状才对得齐——界面不用分情况取字段。
|
|
322
|
+
backendState: 'Running',
|
|
323
|
+
needsLogin: false,
|
|
324
|
+
loginUrl: null,
|
|
325
|
+
error: null,
|
|
326
|
+
enableLink: null,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 打开。成功时返回拿到的网址。
|
|
332
|
+
*
|
|
333
|
+
* 失败要分三种说清楚,因为下一步动作完全不同:
|
|
334
|
+
* · 没装 Tailscale → 去装
|
|
335
|
+
* · tailnet 没开 Serve → 点那条链接(enableLink)
|
|
336
|
+
* · 别的(超时、权限…) → 把原始输出截一段给用户,别吞
|
|
337
|
+
*/
|
|
338
|
+
export async function serveEnable(port) {
|
|
339
|
+
// stopWhen:输出里一出现那条开启链接就立刻收工,不必等它挂满 15 秒。
|
|
340
|
+
// 收工后 r.stopped 为真,**必须当失败处理**:命令是被我叫停的,serve 并没有开成。
|
|
341
|
+
const r = await runTailscale(['serve', '--bg', String(port)], ACTION_TIMEOUT_MS, {
|
|
342
|
+
stopWhen: (out) => Boolean(enableLinkFrom(out)),
|
|
343
|
+
})
|
|
344
|
+
if (!r.ok || r.stopped) return { ok: false, ...classifyEnableFailure(r.out, r.missing) }
|
|
345
|
+
// 开完再问一次,把真实网址取回来——不从这次输出里抠,免得格式变了就错。
|
|
346
|
+
const s = await serveState(port)
|
|
347
|
+
return { ok: true, url: s.url, state: s }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 关掉。
|
|
352
|
+
*
|
|
353
|
+
* 用 `--https=443 off` 而不是 `serve reset`:reset 会把**所有** serve 配置一起清掉,
|
|
354
|
+
* 包括用户自己拿它转发的别的东西。我们只该收自己开的那一扇门。
|
|
355
|
+
*/
|
|
356
|
+
export async function serveDisable() {
|
|
357
|
+
const r = await runTailscale(['serve', '--https=443', 'off'], ACTION_TIMEOUT_MS, {
|
|
358
|
+
stopWhen: (out) => Boolean(enableLinkFrom(out)),
|
|
359
|
+
})
|
|
360
|
+
if (r.ok) return { ok: true }
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
error: `关掉 HTTPS 地址失败了。Tailscale 说:${r.out.trim().slice(0, 300) || '(没有输出)'}`,
|
|
364
|
+
}
|
|
365
|
+
}
|