dsh-mini-remote 0.1.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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/README.zh.md +138 -0
- package/client/client.js +387 -0
- package/client/verify.mjs +514 -0
- package/cordis.patch.yml +5 -0
- package/lib/art/work-1-ready.webp +0 -0
- package/lib/art/work-2-reading.webp +0 -0
- package/lib/art/work-3-typing.webp +0 -0
- package/lib/art/work-4-checking.webp +0 -0
- package/lib/art/work-5-thinking.webp +0 -0
- package/lib/art/work-6-running.webp +0 -0
- package/lib/art/work-7-waiting.webp +0 -0
- package/lib/art/work-8-sprinting.webp +0 -0
- package/lib/build.js +59 -0
- package/lib/cloudflared.js +165 -0
- package/lib/events.js +165 -0
- package/lib/index.js +1206 -0
- package/lib/net.js +104 -0
- package/lib/notify.js +82 -0
- package/lib/page.html +1891 -0
- package/lib/page.js +46 -0
- package/lib/pairing.js +172 -0
- package/lib/server.js +590 -0
- package/lib/store.js +334 -0
- package/lib/tree.js +185 -0
- package/lib/tunnel.js +144 -0
- package/package.json +74 -0
package/lib/page.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把单文件移动端页面读进来,注入两个启动常量;另外负责读立绘。
|
|
3
|
+
*
|
|
4
|
+
* 页面之所以放独立 .html 而不是塞进 JS 模板字符串:省掉满屏的反引号和 ${} 转义,
|
|
5
|
+
* 编辑器也能正常高亮。对外仍然是一个文件。
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const PAGE_PATH = join(HERE, 'page.html')
|
|
13
|
+
const ART_DIR = join(HERE, 'art')
|
|
14
|
+
|
|
15
|
+
let template = null
|
|
16
|
+
|
|
17
|
+
export function renderPage({ defaultMode = 'minimal', needsToken = false, build = '', maxUpload = 0 } = {}) {
|
|
18
|
+
if (template === null) template = readFileSync(PAGE_PATH, 'utf8')
|
|
19
|
+
return template
|
|
20
|
+
.replace('__DEFAULT_MODE__', defaultMode === 'chat' ? 'chat' : 'minimal')
|
|
21
|
+
.replace('__NEEDS_TOKEN__', needsToken ? 'true' : 'false')
|
|
22
|
+
// 立绘地址带上构建指纹(页面自己拼 ?v=__BUILD__),换了图浏览器就不会一直
|
|
23
|
+
// 拿缓存里那张。只留字母数字,别让它有机会往 HTML 里塞东西。
|
|
24
|
+
.replace('__BUILD__', String(build).replace(/[^a-zA-Z0-9]/g, ''))
|
|
25
|
+
// 上传的体积上限由服务端注入,页面里不再写第二份。两处各写一个数迟早会不一致,
|
|
26
|
+
// 而那时候的表现是「页面让你传,服务端拒绝」——最难查的那种不一致。
|
|
27
|
+
.replace('__MAX_UPLOAD__', String(Number(maxUpload) > 0 ? Math.floor(maxUpload) : 0))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 立绘文件名长这样:work-3-typing.webp。 */
|
|
31
|
+
const ART_NAME = /^[a-z0-9-]+\.webp$/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 读一张立绘,读不到返回 null。
|
|
35
|
+
*
|
|
36
|
+
* 名字用白名单卡死形状——不能只靠调用方过滤。这是唯一一处把请求里的字符串
|
|
37
|
+
* 拼进文件路径的地方,`../` 这类穿越必须在这里就堵死。
|
|
38
|
+
*/
|
|
39
|
+
export function readArt(name) {
|
|
40
|
+
if (typeof name !== 'string' || !ART_NAME.test(name)) return null
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(join(ART_DIR, name))
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
package/lib/pairing.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配对信息:手机该扫哪个码、点哪条链接。
|
|
3
|
+
*
|
|
4
|
+
* 这是整个插件唯一一处主动把 token 交出去的地方,所以它只允许「本机」读取
|
|
5
|
+
* (来源判断在 index.js 的路由里)。二维码里连 token 一起编进去——扫完直接
|
|
6
|
+
* 进,不用手敲密码。
|
|
7
|
+
*/
|
|
8
|
+
import QRCode from 'qrcode'
|
|
9
|
+
import { execFileSync } from 'node:child_process'
|
|
10
|
+
import { reachableAddresses } from './net.js'
|
|
11
|
+
|
|
12
|
+
const HINTS = {
|
|
13
|
+
lan: '手机连同一个 Wi-Fi 时用这个',
|
|
14
|
+
tailscale: '在外面(用手机流量)也能用',
|
|
15
|
+
public: '不用装任何东西,出门在外也能用。刚打开时等半分钟再扫;地址每次重启 DSH 会换',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 官方下载页。给没装的人一个明确的下一步。 */
|
|
19
|
+
export const TAILSCALE_DOWNLOAD = 'https://tailscale.com/download'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tailscale 这个命令在不在。
|
|
23
|
+
*
|
|
24
|
+
* 只在**没探到 Tailscale 地址**的时候才问一次,所以正常(装了并且登录着)的情况下
|
|
25
|
+
* 根本不会跑。为什么值得问:探不到地址有两种成因——没装,和装了但没登录。这两种
|
|
26
|
+
* 给用户的下一步动作完全不同(去下载 vs 去登录),界面上那句话该怎么说就取决于它。
|
|
27
|
+
*
|
|
28
|
+
* 超时卡死 1.5 秒:tailscale 装了但后台服务没起来的时候,这个命令会挂住。问不出来
|
|
29
|
+
* 就当作「装了但状态不明」,界面上退回一句两种成因都覆盖的话——宁可说得含糊,
|
|
30
|
+
* 也不能让人白等,或者叫人去装一个他已经装了的软件。
|
|
31
|
+
*/
|
|
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
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 没探到 Tailscale 地址时,给界面一句话;探到了就返回 null(那一行不用出现)。
|
|
44
|
+
*
|
|
45
|
+
* 抽成纯函数是为了能单测:真实那条路要看本机网卡、还要起一个子进程,测起来
|
|
46
|
+
* 又慢又依赖环境。这里只做判断,取数据的事交给调用方。
|
|
47
|
+
*/
|
|
48
|
+
export function tailscaleNotice(detected, cliPresent) {
|
|
49
|
+
if (detected.some((a) => a.kind === 'tailscale')) return null
|
|
50
|
+
return { installed: cliPresent, download: TAILSCALE_DOWNLOAD }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 交给面板的公网网址,该给还是该收回。
|
|
55
|
+
*
|
|
56
|
+
* 2026-09-22:用户照着一个写着「已开启」的面板去扫码,扫出来是 Cloudflare 的
|
|
57
|
+
* Error 1033——cloudflared 进程活着,但它到边缘的连接早断了,插件只认「网址打印出来了」。
|
|
58
|
+
* 补了一道自测(见 index.js 的 startTunnelProbe),判死之后**不再把网址交出去**:
|
|
59
|
+
* 拿到网址就会画一条二维码,而那条现在扫了只会得到 1033。宁可什么都不给,
|
|
60
|
+
* 也不要给一个扫不开的码。
|
|
61
|
+
*
|
|
62
|
+
* 抽成纯函数是为了能单测:真那条路要起 cloudflared、要等定时器、还要联网。
|
|
63
|
+
*
|
|
64
|
+
* @param {boolean|null} healthy true=通,false=判死,null=还不知道
|
|
65
|
+
* @param {string|null|undefined} url cloudflared 打印出来的网址
|
|
66
|
+
*/
|
|
67
|
+
export function tunnelUrlFor(healthy, url) {
|
|
68
|
+
if (healthy === false) return null
|
|
69
|
+
return url ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 隧道自测失败时,这到底是「隧道坏了」还是「本机 DNS 坏了」。
|
|
74
|
+
*
|
|
75
|
+
* 两者必须分开,因为处置完全不同:
|
|
76
|
+
* - `dns`:隧道是好的,只是**这台电脑**解析不了那个域名。手机在外面走运营商的 DNS
|
|
77
|
+
* 很可能好好的,所以**不能收回网址**,只提醒一句。2026-09-22 实测:cloudflared
|
|
78
|
+
* 连着 3 条边缘连接、公共 DNS 都解析得出、强制指到真实 IP 打过去是 200,
|
|
79
|
+
* 可本机路由器(192.168.1.1)就是查不到 *.trycloudflare.com。
|
|
80
|
+
* - `dead`:真的连不上,该判死、该收回网址。
|
|
81
|
+
*/
|
|
82
|
+
export function tunnelProbeVerdict(code) {
|
|
83
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'dns'
|
|
84
|
+
return 'dead'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 二维码编成 PNG 的 data URL,前端直接塞进 <img src>。 */
|
|
88
|
+
async function qr(text) {
|
|
89
|
+
try {
|
|
90
|
+
return await QRCode.toDataURL(text, { errorCorrectionLevel: 'M', margin: 1, width: 220 })
|
|
91
|
+
} catch {
|
|
92
|
+
// 画不出二维码不该让整个面板挂掉,前端会退化成只显示链接
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {object} opts
|
|
99
|
+
* @param {number} opts.port 手机服务实际监听的端口
|
|
100
|
+
* @param {string} opts.token
|
|
101
|
+
* @param {string[]} [opts.bound] 真正绑定成功的地址;不传则用探测结果
|
|
102
|
+
* @param {{enabled?: boolean, url?: string|null, starting?: boolean, error?: string|null}} [opts.tunnel] 公网隧道状态
|
|
103
|
+
*/
|
|
104
|
+
export async function buildPairing({ port, token, bound, tunnel }) {
|
|
105
|
+
const detected = reachableAddresses()
|
|
106
|
+
const usable = bound ? detected.filter((a) => bound.includes(a.address)) : detected
|
|
107
|
+
|
|
108
|
+
const entries = []
|
|
109
|
+
for (const a of usable) {
|
|
110
|
+
const url = `http://${a.address}:${port}/mini?token=${token}`
|
|
111
|
+
entries.push({
|
|
112
|
+
kind: a.kind,
|
|
113
|
+
label: a.label,
|
|
114
|
+
hint: HINTS[a.kind] ?? '',
|
|
115
|
+
url,
|
|
116
|
+
qr: await qr(url),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 公网那条排在最后:它是补充,不是主路
|
|
121
|
+
if (tunnel?.url) {
|
|
122
|
+
const url = `${tunnel.url}/mini?token=${token}`
|
|
123
|
+
entries.push({
|
|
124
|
+
kind: 'public',
|
|
125
|
+
label: '公网',
|
|
126
|
+
hint: HINTS.public,
|
|
127
|
+
url,
|
|
128
|
+
qr: await qr(url),
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const status = {
|
|
133
|
+
enabled: Boolean(tunnel?.enabled),
|
|
134
|
+
up: Boolean(tunnel?.url),
|
|
135
|
+
starting: Boolean(tunnel?.starting),
|
|
136
|
+
error: tunnel?.error ?? null,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 没探到 Tailscale 地址时,也要给界面一句话。
|
|
141
|
+
*
|
|
142
|
+
* 原来那一行是**直接不出现**的——用户看到的不是「你没有 Tailscale」,而是
|
|
143
|
+
* 「这里什么都没有」。他不知道自己缺了什么,也就不会想到去装。而他恰恰是
|
|
144
|
+
* 最需要这条路的人:不装 Tailscale,手机出门在外就只剩公网隧道那条路。
|
|
145
|
+
*/
|
|
146
|
+
const tailscale = tailscaleNotice(detected, tailscaleCliPresent())
|
|
147
|
+
|
|
148
|
+
if (!entries.length) {
|
|
149
|
+
// 一条能用的都没有。三种成因要分开说,否则用户不知道该去改配置、去查网络,
|
|
150
|
+
// 还是去查隧道——这三件事的下一步动作完全不同。
|
|
151
|
+
if (status.enabled && status.error) {
|
|
152
|
+
return { ok: false, error: `公网访问没起来:${status.error}`, tunnel: status, tailscale }
|
|
153
|
+
}
|
|
154
|
+
if (detected.length) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: '现在设置成只有这台电脑能连。把 settings.json 里的 bindAddress 改成 "auto",'
|
|
158
|
+
+ '重启 DSH 之后手机就能连了。',
|
|
159
|
+
tunnel: status,
|
|
160
|
+
tailscale,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: '没找到手机能连上的地址。检查一下这台电脑的 Wi-Fi/网线,或者 Tailscale 是否登录。',
|
|
166
|
+
tunnel: status,
|
|
167
|
+
tailscale,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { ok: true, port, token, entries, tunnel: status, tailscale }
|
|
172
|
+
}
|