dsh-pet-live2d 2.3.4 → 3.0.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 +115 -0
- package/README.md +1 -1
- package/docs/README.md +19 -6
- package/docs/banner.png +0 -0
- package/docs/looks.jpg +0 -0
- package/docs/panel.jpg +0 -0
- package/lib/client.js +1133 -129
- package/lib/desktop.js +275 -0
- package/lib/display.js +291 -0
- package/lib/index.js +146 -5
- package/lib/settings.js +98 -0
- package/package.json +6 -1
- package/pets/ds-whale-girl/pet.json +5 -0
- package/screenshots.json +5 -0
package/lib/desktop.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// 桌面端二进制的定位(宿主半区)。
|
|
2
|
+
//
|
|
3
|
+
// 桌宠有两条呈现路径:**页面内**(本插件自己渲染,随 DSH 启停)与**桌面上**(一个原生
|
|
4
|
+
// 窗口进程,DSH 关掉她也能站着)。后者需要一个二进制,它由**平台子包**随主包一起装:
|
|
5
|
+
//
|
|
6
|
+
// Windows x64 dsh-pet-live2d-desktop-win32-x64 (声明了 os/cpu,别的平台装不上)
|
|
7
|
+
// 将来的 …-darwin-arm64 / …-linux-x64 (同一个解析器,加一行清单即可)
|
|
8
|
+
//
|
|
9
|
+
// 为什么用"平台子包 + optionalDependencies"而不是把 exe 塞进主包:非 Windows 用户不该
|
|
10
|
+
// 为一个跑不了的 9MB 买单,而 npm 的 `os`/`cpu` 字段正好能让不匹配的平台**静默跳过**。
|
|
11
|
+
// 于是 Windows 用户装完就能用(无感),macOS 用户装完没这一项(也无感、不报错)。
|
|
12
|
+
//
|
|
13
|
+
// 解析顺序是**从"用户显式指定"到"包管理器装的"再到"我们自己下过的"**,每一步都有它存在的
|
|
14
|
+
// 理由,见下面 resolveDesktopBinary 的注释。
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs'
|
|
16
|
+
import { gunzip } from 'node:zlib'
|
|
17
|
+
import { promisify } from 'node:util'
|
|
18
|
+
import { dirname, join, resolve } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
|
|
21
|
+
const gunzipAsync = promisify(gunzip)
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
|
|
25
|
+
/** 当前平台对应的子包名(没有就是"这个平台还没有桌面版")。 */
|
|
26
|
+
export function desktopPackageName(platform = process.platform, arch = process.arch) {
|
|
27
|
+
const table = {
|
|
28
|
+
'win32-x64': 'dsh-pet-live2d-desktop-win32-x64',
|
|
29
|
+
// 将来加构建时在这里补:'darwin-arm64' / 'linux-x64' …
|
|
30
|
+
}
|
|
31
|
+
return table[platform + '-' + arch]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 子包里 exe 的相对路径(各平台同一个名字,省得再分叉)。 */
|
|
35
|
+
const BINARY_NAME = {
|
|
36
|
+
win32: 'dsh-pet-live2d-desktop.exe',
|
|
37
|
+
darwin: 'dsh-pet-live2d-desktop',
|
|
38
|
+
linux: 'dsh-pet-live2d-desktop',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 主包版本(子包版本必须与它一致,`tools/npm-prepare-subpackage.mjs` 会校)。 */
|
|
42
|
+
function ownVersion() {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version ?? '0.0.0'
|
|
45
|
+
} catch {
|
|
46
|
+
return '0.0.0'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 找 `node_modules/<name>`:从插件自己的位置**逐级向上**找。
|
|
52
|
+
*
|
|
53
|
+
* 为什么要向上找:装法有三种,落点不一样 ——
|
|
54
|
+
* * 直接从仓库装(`github:…#path:/dsh-live2d-pet`):插件在 `…/node_modules/dsh-pet-live2d/`,
|
|
55
|
+
* 子包与它**同级**;
|
|
56
|
+
* * 从 npm 装在 profile 里:同样同级;
|
|
57
|
+
* * pnpm 的隔离布局:真身在 `.pnpm/<name>/node_modules/…`,同级看到的可能是软链。
|
|
58
|
+
* 只要从插件目录向上几层里任一处的 `node_modules/<name>` 存在,就是它。
|
|
59
|
+
*/
|
|
60
|
+
function findInNodeModules(name, from = HERE) {
|
|
61
|
+
let dir = resolve(from)
|
|
62
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
63
|
+
const candidate = join(dir, 'node_modules', name)
|
|
64
|
+
if (existsSync(candidate)) return candidate
|
|
65
|
+
const parent = dirname(dir)
|
|
66
|
+
if (parent === dir) break
|
|
67
|
+
dir = parent
|
|
68
|
+
}
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 我们自己下过的那份放哪儿(惰性下载兜底、或用户手动放的)。 */
|
|
73
|
+
export function managedBinPath(home) {
|
|
74
|
+
return join(home, 'bin', BINARY_NAME[process.platform] ?? 'dsh-pet-live2d-desktop')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 定位桌面端二进制。
|
|
79
|
+
*
|
|
80
|
+
* 顺序与理由:
|
|
81
|
+
* 1. `DSH_PET_DESKTOP_BIN` —— 用户/测试显式指定,永远最优先(排查时不至于被"包里的
|
|
82
|
+
* 那份"盖住);
|
|
83
|
+
* 2. 平台子包 —— 正常路径,装插件时就带下来了;
|
|
84
|
+
* 3. `%DSH_HOME%\bin\` —— 我们自己下的那份,或者用户手动放的;
|
|
85
|
+
* 4. 插件包旁边的 `desktop-bin/` —— 开发期(`dsh-live2d-pet-desktop` 就在仓库里时)。
|
|
86
|
+
*
|
|
87
|
+
* @returns {{ path: string, source: string, size: number } | undefined}
|
|
88
|
+
*/
|
|
89
|
+
export function resolveDesktopBinary(options = {}) {
|
|
90
|
+
const home = options.home
|
|
91
|
+
const name = desktopPackageName()
|
|
92
|
+
const candidates = []
|
|
93
|
+
|
|
94
|
+
const explicit = process.env.DSH_PET_DESKTOP_BIN
|
|
95
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') {
|
|
96
|
+
candidates.push([resolve(explicit.trim()), 'env:DSH_PET_DESKTOP_BIN'])
|
|
97
|
+
}
|
|
98
|
+
if (name !== undefined) {
|
|
99
|
+
const pack = findInNodeModules(name)
|
|
100
|
+
if (pack !== undefined) {
|
|
101
|
+
candidates.push([join(pack, 'bin', BINARY_NAME[process.platform]), 'package:' + name])
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (typeof home === 'string' && home !== '') {
|
|
105
|
+
candidates.push([managedBinPath(home), 'managed'])
|
|
106
|
+
}
|
|
107
|
+
candidates.push([join(HERE, '..', 'desktop-bin', BINARY_NAME[process.platform]), 'repo-dev'])
|
|
108
|
+
|
|
109
|
+
for (const [path, source] of candidates) {
|
|
110
|
+
try {
|
|
111
|
+
const stat = statSync(path)
|
|
112
|
+
// 下限 32KB:不是为了"差不多就行",而是为了**排除把网页当二进制**——
|
|
113
|
+
// 404 的 HTML、package.json、占位文件都远小于它,而真二进制 9MB。
|
|
114
|
+
// (原来写 1MB,结果夹具里的 1MB 整文件被挡在门外 —— 阈值该贴着"要排除什么"定,
|
|
115
|
+
// 不该拍一个看起来很大的数。)
|
|
116
|
+
if (stat.isFile() && stat.size > 32 * 1024) {
|
|
117
|
+
return { path, source, size: stat.size }
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
/* 下一个 */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 这个平台有没有桌面版(没有时设置里那一项要灰掉并说明原因)。 */
|
|
127
|
+
export function desktopSupported(platform = process.platform, arch = process.arch) {
|
|
128
|
+
return desktopPackageName(platform, arch) !== undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 给用户看的一句话:怎么才能有桌面版。
|
|
133
|
+
*
|
|
134
|
+
* 分三种情况说清楚 —— "本平台还没有构建"和"装的时候被跳过了"是两件不同的事,
|
|
135
|
+
* 混在一起说会让用户去装一个根本不存在的东西。
|
|
136
|
+
*/
|
|
137
|
+
export function desktopHint(options = {}) {
|
|
138
|
+
const name = desktopPackageName()
|
|
139
|
+
if (name === undefined) {
|
|
140
|
+
return `本平台(${process.platform}-${process.arch})还没有桌面版构建;她可以一直在 DSH 页面里。`
|
|
141
|
+
}
|
|
142
|
+
const found = resolveDesktopBinary(options)
|
|
143
|
+
if (found !== undefined) return '桌面端已就绪(' + found.source + ')'
|
|
144
|
+
return '点「下载桌面端」拉一份(约 5MB)'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 版本一致性:子包版本与主包不一致时给出提示(发布时最容易搞错的一处)。 */
|
|
148
|
+
export function desktopVersionCheck(options = {}) {
|
|
149
|
+
const name = desktopPackageName()
|
|
150
|
+
if (name === undefined) return { ok: true, reason: 'unsupported-platform' }
|
|
151
|
+
const pack = findInNodeModules(name)
|
|
152
|
+
if (pack === undefined) return { ok: true, reason: 'not-installed' }
|
|
153
|
+
try {
|
|
154
|
+
const version = JSON.parse(readFileSync(join(pack, 'package.json'), 'utf8')).version
|
|
155
|
+
return { ok: version === ownVersion(), reason: version, expected: ownVersion() }
|
|
156
|
+
} catch {
|
|
157
|
+
return { ok: true, reason: 'unreadable' }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------- 惰性下载
|
|
162
|
+
//
|
|
163
|
+
// 为什么需要它:`optionalDependencies` 是**装插件那一刻**解析的,"用户点了开关才下载"做不到。
|
|
164
|
+
// 于是没带上那份二进制的情况是真实存在的 —— `--no-optional`、手动删过 node_modules、
|
|
165
|
+
// 或以后加了别的平台。这时**不该甩给用户一个 GitHub 链接**。
|
|
166
|
+
//
|
|
167
|
+
// 走 npm 自己的 tarball(不是 GitHub Release):
|
|
168
|
+
// * 不用我们搭服务器、没有 GitHub 的 60 次/小时限流;
|
|
169
|
+
// * 平台子包在 npm 上就是给"按平台分发二进制"设计的(esbuild / swc / ripgrep 都这么发);
|
|
170
|
+
// * Cubism Core 已经嵌在 exe 里,所以**不需要**再单独下任何东西。
|
|
171
|
+
//
|
|
172
|
+
// 一个**极小的 tar 解析器**:不引 `tar` 依赖(插件包要保持精简),而 GNU/ustar 的头格式
|
|
173
|
+
// 稳定且我们有明确的文件名要找(`package/bin/<exe>`)。chunked 在别处踩过,这里同理只认
|
|
174
|
+
// **未压缩长度**已知的常规文件。
|
|
175
|
+
|
|
176
|
+
const TAR_BLOCK = 512
|
|
177
|
+
|
|
178
|
+
/** 从一个 tar 里取出指定路径的文件内容(够用即止,不做链接/稀疏文件)。 */
|
|
179
|
+
export function readFileFromTar(buffer, wantedPath) { let offset = 0
|
|
180
|
+
let longName
|
|
181
|
+
while (offset + TAR_BLOCK <= buffer.length) {
|
|
182
|
+
const header = buffer.subarray(offset, offset + TAR_BLOCK)
|
|
183
|
+
// 全零块 = 归档结束。
|
|
184
|
+
if (header.every((byte) => byte === 0)) break
|
|
185
|
+
const rawName = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
|
|
186
|
+
const sizeText = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim()
|
|
187
|
+
const size = Number.parseInt(sizeText, 8) || 0
|
|
188
|
+
const typeFlag = String.fromCharCode(header[156] || 48)
|
|
189
|
+
const dataStart = offset + TAR_BLOCK
|
|
190
|
+
const name = longName ?? rawName
|
|
191
|
+
longName = undefined
|
|
192
|
+
if (typeFlag === 'L') {
|
|
193
|
+
// GNU longname:这块的内容是下一个条目的真实路径。
|
|
194
|
+
longName = buffer.subarray(dataStart, dataStart + size).toString('utf8').replace(/\0.*$/, '')
|
|
195
|
+
} else if (name === wantedPath || name === './' + wantedPath) {
|
|
196
|
+
return buffer.subarray(dataStart, dataStart + size)
|
|
197
|
+
}
|
|
198
|
+
offset = dataStart + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK
|
|
199
|
+
}
|
|
200
|
+
return undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 从 npm 拉平台子包,把 exe 放到"我们自己管的那份"路径下(`%DSH_HOME%\bin\`)。
|
|
205
|
+
*
|
|
206
|
+
* 解析器的顺序里 `managedBinPath` 就在子包之后,所以下完之后**不需要重启**就找得到。
|
|
207
|
+
*/
|
|
208
|
+
export async function downloadDesktopBinary(options = {}) {
|
|
209
|
+
const { home, log } = options
|
|
210
|
+
const name = desktopPackageName()
|
|
211
|
+
if (name === undefined) return { ok: false, reason: 'unsupported-platform' }
|
|
212
|
+
if (typeof home !== 'string' || home === '') return { ok: false, reason: 'no-home' }
|
|
213
|
+
const version = ownVersion()
|
|
214
|
+
const registry = options.registry ?? 'https://registry.npmjs.org'
|
|
215
|
+
const note = (message) => { if (typeof log === 'function') log('[desktop] ' + message) }
|
|
216
|
+
|
|
217
|
+
// 先问注册表要 tarball 地址(而不是拼 URL):镜像/私有源都能跟着走。
|
|
218
|
+
let tarballUrl = registry + '/' + name + '/-/' + name + '-' + version + '.tgz'
|
|
219
|
+
try {
|
|
220
|
+
const response = await fetch(registry + '/' + encodeURIComponent(name), { headers: { accept: 'application/json' } })
|
|
221
|
+
if (response.ok) {
|
|
222
|
+
const meta = await response.json()
|
|
223
|
+
const url = meta?.versions?.[version]?.dist?.tarball
|
|
224
|
+
if (typeof url === 'string' && url.startsWith('https://')) tarballUrl = url
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
/* 用上面拼的那个兜底 */
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
note('下载 ' + tarballUrl)
|
|
231
|
+
let bytes
|
|
232
|
+
try {
|
|
233
|
+
const response = await fetch(tarballUrl)
|
|
234
|
+
if (!response.ok) return { ok: false, reason: 'download-failed', status: response.status, url: tarballUrl }
|
|
235
|
+
bytes = Buffer.from(await response.arrayBuffer())
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return { ok: false, reason: 'download-failed', detail: String(error && error.message), url: tarballUrl }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// npm 的 tarball 是 gzip。绝大多数情况下它就是这样,但**别赌**:万一拿到的是未压缩的
|
|
241
|
+
// tar(镜像/代理改过、或响应头骗人),gzip 解压会抛一个看不懂的错。这里按魔数认
|
|
242
|
+
// (`1f 8b` 是 gzip),不是 gzip 就当成裸 tar 直接用。
|
|
243
|
+
let tar
|
|
244
|
+
try {
|
|
245
|
+
tar = bytes[0] === 0x1f && bytes[1] === 0x8b ? await gunzipAsync(bytes) : bytes
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return { ok: false, reason: 'extract-failed', detail: 'gunzip: ' + String(error && error.message) }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let inner
|
|
251
|
+
try {
|
|
252
|
+
inner = readFileFromTar(tar, 'package/bin/' + BINARY_NAME[process.platform])
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return { ok: false, reason: 'extract-failed', detail: String(error && error.message) }
|
|
255
|
+
}
|
|
256
|
+
if (inner === undefined) return { ok: false, reason: 'binary-not-in-tarball', url: tarballUrl }
|
|
257
|
+
|
|
258
|
+
const target = managedBinPath(home)
|
|
259
|
+
try {
|
|
260
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
261
|
+
writeFileSync(target, inner)
|
|
262
|
+
if (process.platform !== 'win32') chmodSync(target, 0o755)
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return { ok: false, reason: 'write-failed', detail: String(error && error.message), target }
|
|
265
|
+
}
|
|
266
|
+
note('已写入 ' + target + '(' + (inner.length / 1024 / 1024).toFixed(2) + ' MB)')
|
|
267
|
+
return { ok: true, path: target, bytes: inner.length, url: tarballUrl }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 下一句该干什么(设置页那一行直接用它,不自己编词)。 */
|
|
271
|
+
export function desktopNextStep(options = {}) {
|
|
272
|
+
const name = desktopPackageName()
|
|
273
|
+
if (name === undefined) return '本平台还没有桌面版构建'
|
|
274
|
+
return resolveDesktopBinary(options) === undefined ? '点「下载桌面端」拉一份(约 5MB)' : ''
|
|
275
|
+
}
|
package/lib/display.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// 显示层(宿主半区):桌宠在**页面内**还是**桌面上**,以及"谁来管这只宠物"。
|
|
2
|
+
//
|
|
3
|
+
// 两条呈现路径都可能活着(插件在 DSH 里渲染 + 一个原生窗口进程),所以需要一个判定,
|
|
4
|
+
// 否则用户会看见两只。判定靠一个**两端都能读的文件**:
|
|
5
|
+
//
|
|
6
|
+
// %DSH_HOME%\pet-desktop.json
|
|
7
|
+
// { mode: "auto"|"inline"|"desktop", desktopPid, desktopStartedAt, at }
|
|
8
|
+
//
|
|
9
|
+
// 规则**只有一条**,两端各自算、结论必然一致:
|
|
10
|
+
//
|
|
11
|
+
// 桌面端心跳新鲜(< 6 秒)且 mode ≠ "inline" → 桌面端是 owner
|
|
12
|
+
// 否则 → 页面内是 owner
|
|
13
|
+
//
|
|
14
|
+
// Rust 侧那份实现在 `dsh-live2d-pet-desktop/src-tauri/src/host/display.rs`,**逐字对应**
|
|
15
|
+
// (各自的单元测试都钉着同一张真值表)。改规则必须两边一起改。
|
|
16
|
+
//
|
|
17
|
+
// 为什么不走 DSH 的进程内服务:桌面端是另一个进程,拿不到 `ctx.on(...)`。一个文件加一条
|
|
18
|
+
// 纯规则,是这两边唯一都能用的东西。
|
|
19
|
+
import { spawn } from 'node:child_process'
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
import { downloadDesktopBinary } from './desktop.js'
|
|
24
|
+
|
|
25
|
+
/** 心跳有效期:桌面端每 1 秒刷一次(见它的 display_loop),这里给六倍余量。 */
|
|
26
|
+
export const HEARTBEAT_TTL_MS = 6000
|
|
27
|
+
|
|
28
|
+
export const MODES = ['auto', 'inline', 'desktop']
|
|
29
|
+
|
|
30
|
+
export function preferencePath(home) {
|
|
31
|
+
return join(home, 'pet-desktop.json')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 读偏好文件;读不出来就当空(老装机本来就没有)。 */
|
|
35
|
+
export function readPreference(home) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(readFileSync(preferencePath(home), 'utf8'))
|
|
38
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
39
|
+
} catch {
|
|
40
|
+
return {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 写偏好文件(合并写:两端都在往里写,别把对方的东西擦掉)。 */
|
|
45
|
+
export function writePreference(home, patch) {
|
|
46
|
+
const current = Object.assign(readPreference(home), patch)
|
|
47
|
+
try {
|
|
48
|
+
mkdirSync(home, { recursive: true })
|
|
49
|
+
writeFileSync(preferencePath(home), JSON.stringify(current, null, 2) + '\n')
|
|
50
|
+
} catch {
|
|
51
|
+
/* 只读盘之类:这次生效,下次不记得 */
|
|
52
|
+
}
|
|
53
|
+
return current
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function normaliseMode(raw) {
|
|
57
|
+
return typeof raw === 'string' && MODES.includes(raw) ? raw : 'auto'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function heartbeatFresh(preference, now = Date.now()) {
|
|
61
|
+
const at = typeof preference?.at === 'number' ? preference.at : 0
|
|
62
|
+
const pid = typeof preference?.desktopPid === 'number' ? preference.desktopPid : 0
|
|
63
|
+
return pid !== 0 && at !== 0 && now - at < HEARTBEAT_TTL_MS
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* **判定规则**(唯一一条)。
|
|
68
|
+
* @returns {'desktop'|'inline'}
|
|
69
|
+
*/
|
|
70
|
+
export function computeOwner(mode, heartbeatOk) {
|
|
71
|
+
return mode !== 'inline' && heartbeatOk === true ? 'desktop' : 'inline'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 进程还活着吗(**只用于诊断**)。
|
|
76
|
+
*
|
|
77
|
+
* ⚠️ **不要拿它当"在不在跑"的判据。** Windows 与类 Unix 都会**重用 pid**:桌面端被
|
|
78
|
+
* 任务管理器杀掉(没走退出清理,心跳文件里还留着那个 pid)之后,那个号可能已经被别的
|
|
79
|
+
* 进程拿走,`process.kill(pid, 0)` 于是说"活着"—— 然后页面里那只就永远让位,
|
|
80
|
+
* 用户看到"没反应"。实测踩过一次:杀掉桌面端后 owner 仍报 desktop。
|
|
81
|
+
*
|
|
82
|
+
* 判据只用**心跳**:它每秒刷新,6 秒不刷新就是没了(进程被杀 / 卡死 / 崩了都覆盖),
|
|
83
|
+
* 而且不依赖任何平台细节。
|
|
84
|
+
*/
|
|
85
|
+
function pidAlive(pid) {
|
|
86
|
+
if (typeof pid !== 'number' || pid <= 0) return false
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0)
|
|
89
|
+
return true
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// EPERM = 进程存在但不是我们的;ESRCH = 不存在。
|
|
92
|
+
return error?.code === 'EPERM'
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 显示层控制器:读偏好、必要时拉起/收掉桌面端、回答"现在该谁显示"。
|
|
98
|
+
*
|
|
99
|
+
* 一个实例挂在插件生命周期上(`apply()` 里建、dispose 时收),进程内的状态就是它自己。
|
|
100
|
+
*/
|
|
101
|
+
export function createDisplayLayer(options) {
|
|
102
|
+
const { home, resolveBinary, log } = options
|
|
103
|
+
const note = (message) => {
|
|
104
|
+
if (typeof log === 'function') log('[display] ' + message)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 拉起桌面端时用的上游地址。
|
|
109
|
+
*
|
|
110
|
+
* **从页面自己报过来**(`/layer` 请求的 Origin/Host),不是我们猜的:DSH 的端口是配置的,
|
|
111
|
+
* 猜错的话挂载模式会连到一个不存在的上游(那就成了"挂上去了但什么也没有")。
|
|
112
|
+
* 页面还没报过之前,用 3080 兜底 —— 那是 `dsh web` 的默认值。
|
|
113
|
+
*/
|
|
114
|
+
let dshUrl = 'http://127.0.0.1:3080'
|
|
115
|
+
const setDshUrl = (value) => {
|
|
116
|
+
if (typeof value !== 'string' || value.trim() === '') return
|
|
117
|
+
if (!/^https?:\/\//.test(value)) return
|
|
118
|
+
dshUrl = value.replace(/\/$/, '')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 我们自己拉起来的那个进程(0 = 没拉)。 */
|
|
122
|
+
let child = null
|
|
123
|
+
let childPid = 0
|
|
124
|
+
/** 上次尝试拉起的时间:失败之后不要每秒重试,会刷屏。 */
|
|
125
|
+
let lastAttempt = 0
|
|
126
|
+
const RETRY_MS = 30000
|
|
127
|
+
|
|
128
|
+
const preference = () => readPreference(home)
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 在不在跑 —— **只看心跳**(见 `pidAlive` 上的说明:pid 会被重用,不能当判据)。
|
|
132
|
+
*
|
|
133
|
+
* 心跳每秒刷新一次,6 秒不刷新就当作没了:进程被杀、卡死、崩了三种情况都覆盖到。
|
|
134
|
+
*/
|
|
135
|
+
const running = () => heartbeatFresh(preference())
|
|
136
|
+
|
|
137
|
+
/** 状态:设置页与 `/owner` 都读它。 */
|
|
138
|
+
function status() {
|
|
139
|
+
const current = preference()
|
|
140
|
+
const mode = normaliseMode(current.mode)
|
|
141
|
+
const alive = heartbeatFresh(current)
|
|
142
|
+
return {
|
|
143
|
+
mode,
|
|
144
|
+
owner: computeOwner(mode, alive),
|
|
145
|
+
desktopRunning: alive,
|
|
146
|
+
desktopPid: typeof current.desktopPid === 'number' ? current.desktopPid : 0,
|
|
147
|
+
desktopSpawnedByPlugin: childPid !== 0,
|
|
148
|
+
heartbeatAt: typeof current.at === 'number' ? current.at : 0,
|
|
149
|
+
ttlMs: HEARTBEAT_TTL_MS,
|
|
150
|
+
// 诊断用:仅当心跳说"没了"而那个 pid 号还占着时,它才有信息量(pid 重用)。
|
|
151
|
+
pidStillTaken: !alive && pidAlive(current.desktopPid),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stop() {
|
|
156
|
+
if (child === null) return false
|
|
157
|
+
try {
|
|
158
|
+
child.kill()
|
|
159
|
+
note('已收掉桌面端(pid ' + childPid + ')')
|
|
160
|
+
} catch {
|
|
161
|
+
/* 已经退了 */
|
|
162
|
+
}
|
|
163
|
+
child = null
|
|
164
|
+
childPid = 0
|
|
165
|
+
// 心跳留给它自己清;这里顺手清一次,页面里那只不用等超时。
|
|
166
|
+
writePreference(home, { desktopPid: 0, at: 0 })
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function start() {
|
|
171
|
+
const binary = resolveBinary()
|
|
172
|
+
if (binary === undefined) {
|
|
173
|
+
note('找不到桌面端二进制,无法拉起(' + (options.hint ?? '') + ')')
|
|
174
|
+
return { ok: false, reason: 'binary-missing' }
|
|
175
|
+
}
|
|
176
|
+
if (childPid !== 0 || running()) return { ok: true, reason: 'already-running' }
|
|
177
|
+
if (Date.now() - lastAttempt < RETRY_MS && lastAttempt !== 0) {
|
|
178
|
+
return { ok: false, reason: 'retry-cooldown' }
|
|
179
|
+
}
|
|
180
|
+
lastAttempt = Date.now()
|
|
181
|
+
try {
|
|
182
|
+
// `detached` + `unref`:她是**独立的**窗口进程,DSH 关掉她还得站着。
|
|
183
|
+
child = spawn(binary.path, ['--attach', dshUrl], {
|
|
184
|
+
detached: true,
|
|
185
|
+
stdio: 'ignore',
|
|
186
|
+
windowsHide: true,
|
|
187
|
+
})
|
|
188
|
+
childPid = child.pid ?? 0
|
|
189
|
+
child.unref()
|
|
190
|
+
note('已拉起桌面端:' + binary.path + ' --attach ' + dshUrl + '(pid ' + childPid + ')')
|
|
191
|
+
return { ok: true, reason: 'spawned', pid: childPid }
|
|
192
|
+
} catch (error) {
|
|
193
|
+
child = null
|
|
194
|
+
childPid = 0
|
|
195
|
+
note('拉起桌面端失败:' + String(error && error.message))
|
|
196
|
+
return { ok: false, reason: 'spawn-failed' }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 每秒一次:按偏好把两边摆正。
|
|
202
|
+
*
|
|
203
|
+
* auto 桌面端在跑 → 让她待着;没跑 → 也不主动拉(用户没要求过桌面)
|
|
204
|
+
* desktop 必须有一个:没跑就拉起(失败就只是提示,不崩)
|
|
205
|
+
* inline 收掉我们拉的那个,桌面端自己会让位(它读同一个文件)
|
|
206
|
+
*/
|
|
207
|
+
function reconcile() {
|
|
208
|
+
const mode = normaliseMode(preference().mode)
|
|
209
|
+
if (mode === 'desktop') {
|
|
210
|
+
const alive = running()
|
|
211
|
+
if (!alive) {
|
|
212
|
+
const result = start()
|
|
213
|
+
if (result.reason === 'binary-missing') {
|
|
214
|
+
// 说清楚"下一句该干什么",而不是只报个失败。
|
|
215
|
+
note(options.hint ?? '桌面端未安装')
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return status()
|
|
219
|
+
}
|
|
220
|
+
if (mode === 'inline' && childPid !== 0) stop()
|
|
221
|
+
return status()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function setMode(mode) {
|
|
225
|
+
const next = normaliseMode(mode)
|
|
226
|
+
writePreference(home, { mode: next })
|
|
227
|
+
note('显示层切换为 ' + next)
|
|
228
|
+
return reconcile()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 惰性下载:**先回话、后台下**。
|
|
233
|
+
*
|
|
234
|
+
* 不能让 HTTP 请求等下完那 5MB —— 那个请求会挂在那儿,而页面每秒轮询一次 `/layer`,
|
|
235
|
+
* 用户的观感就是"卡住了,还是没反应"。所以立刻回 `{started:true}`,进度写进
|
|
236
|
+
* `downloadState` 由每秒的轮询带回去;下完**不需要重启**(解析器会看 `%DSH_HOME%\bin\`)。
|
|
237
|
+
*/
|
|
238
|
+
let downloadState = { state: 'idle', at: 0 }
|
|
239
|
+
function startDownload() {
|
|
240
|
+
if (downloadState.state === 'downloading') return { started: false, reason: 'already-running' }
|
|
241
|
+
downloadState = { state: 'downloading', at: Date.now() }
|
|
242
|
+
note('开始下载桌面端二进制')
|
|
243
|
+
downloadDesktopBinary({ home, log })
|
|
244
|
+
.then((result) => {
|
|
245
|
+
downloadState = result.ok === true
|
|
246
|
+
? { state: 'done', at: Date.now(), path: result.path, bytes: result.bytes }
|
|
247
|
+
: { state: 'failed', at: Date.now(), reason: result.reason, detail: result.detail ?? null }
|
|
248
|
+
note('下载结束:' + JSON.stringify(downloadState))
|
|
249
|
+
// 下完就按当前 mode 摆正一次:mode 已经是 desktop 的话这里就把它拉起来了,
|
|
250
|
+
// 用户不用再点第二次。
|
|
251
|
+
if (result.ok === true) reconcile()
|
|
252
|
+
})
|
|
253
|
+
.catch((error) => {
|
|
254
|
+
downloadState = { state: 'failed', at: Date.now(), reason: 'exception', detail: String(error && error.message) }
|
|
255
|
+
})
|
|
256
|
+
return { started: true }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function dispose() {
|
|
260
|
+
// 插件停掉(DSH 退出 / 插件卸载)时,**不**杀掉用户自己在跑的桌面端;
|
|
261
|
+
// 只收掉我们拉起来的那个 —— 那本来就是"插件的延伸"。
|
|
262
|
+
stop()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
status,
|
|
267
|
+
reconcile,
|
|
268
|
+
setMode,
|
|
269
|
+
setDshUrl,
|
|
270
|
+
start,
|
|
271
|
+
stop,
|
|
272
|
+
startDownload,
|
|
273
|
+
downloadState: () => downloadState,
|
|
274
|
+
dispose,
|
|
275
|
+
preferencePath: () => preferencePath(home),
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 桌面上还有没有别的实例在跑(诊断用)。 */
|
|
280
|
+
export function desktopProcessState(home) {
|
|
281
|
+
const current = readPreference(home)
|
|
282
|
+
const fresh = heartbeatFresh(current)
|
|
283
|
+
return {
|
|
284
|
+
pid: typeof current.desktopPid === 'number' ? current.desktopPid : 0,
|
|
285
|
+
// `running` 才是判据;`alive` 只是"那个 pid 号现在还占着"(可能已经是别人的进程)。
|
|
286
|
+
running: fresh,
|
|
287
|
+
pidStillTaken: pidAlive(current.desktopPid),
|
|
288
|
+
heartbeatFresh: fresh,
|
|
289
|
+
file: existsSync(preferencePath(home)),
|
|
290
|
+
}
|
|
291
|
+
}
|