dsh-x-core 0.1.25 → 0.1.26-rc.2
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/apply-update.mjs +1 -1
- package/compat/instance-link.cjs +23 -7
- package/desktop.js +1 -1
- package/instance-link.js +3 -1
- package/package.json +1 -1
- package/platform.js +10 -10
- package/plugins.js +45 -2
- package/public/index.html +469 -101
- package/public/launcher.css +41 -1
- package/recovery.js +18 -3
- package/registry.js +2 -1
- package/server.js +290 -29
- package/settings.js +31 -0
package/apply-update.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 流程:等父进程退出 → 备份旧核心目录 → 换上新解压好的暂存目录 → 重启外壳 →
|
|
4
4
|
// 等新版本写出启动标记;没等到就回滚到备份再重启。所有结果写进 update.log。
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// 只替换核心目录(用户目录的 core/current;源码/npm 模式下是别的目录),不碰 DSH.exe 和
|
|
7
7
|
// Electron 运行时(所以不受文件锁影响)。参数全部走命令行;这里只依赖 Node 内置模块,
|
|
8
8
|
// 脚本可以放在临时目录里跑。
|
|
9
9
|
|
package/compat/instance-link.cjs
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
'use strict'
|
|
6
6
|
const LINK = Object.freeze({
|
|
7
7
|
env: 'DSH_X_INSTANCE_LINK', host: '127.0.0.1', maxFrameBytes: 16384,
|
|
8
|
-
pollMs: 250, connectTimeoutMs: 5000,
|
|
8
|
+
pollMs: 250, connectTimeoutMs: 5000, exitGraceMs: 2000,
|
|
9
|
+
// 服务端收到连接后等第一帧的上限。客户端把握手帧排进内核后可能被 dsh 的同步启动
|
|
10
|
+
// 占住事件循环几十秒,管理器自己的循环也可能忙;5 秒那条线不能拿来判断对方还活着。
|
|
11
|
+
handshakeTimeoutMs: 60_000,
|
|
12
|
+
ready: /dsh web:\s+(https?:\/\/[^\s]+)/,
|
|
9
13
|
})
|
|
10
14
|
module.exports = LINK
|
|
11
15
|
|
|
@@ -19,25 +23,37 @@ const profile = profileAt < 0 ? args[config?.profileArgIndex || 0] : args[profil
|
|
|
19
23
|
if (isMainThread && config && process.argv[1] && resolve(process.argv[1]) === config.entry && profile === config.profile) {
|
|
20
24
|
const { createConnection } = require('node:net')
|
|
21
25
|
const { StringDecoder } = require('node:string_decoder')
|
|
26
|
+
const { writeSync } = require('node:fs')
|
|
22
27
|
const socket = createConnection({ host: LINK.host, port: config.port })
|
|
23
28
|
// 管道不延长 dsh 的寿命;但失去监管时不能留下一个无人管理、拿不到 token 的进程。
|
|
24
29
|
socket.unref()
|
|
25
30
|
let stopping = false
|
|
26
|
-
const fail = () => {
|
|
31
|
+
const fail = (reason) => {
|
|
27
32
|
if (stopping) return
|
|
28
33
|
stopping = true
|
|
34
|
+
// 失败原因必须落进管理器的 failure.tail:直接同步写 fd 2,不经过下面被包装的
|
|
35
|
+
// stdout/stderr,也不会在随后被 SIGNAL 终止时丢掉(否则失败页面只有「零输出 exit 1」)。
|
|
36
|
+
try { writeSync(2, `dsh-x: 实例管道${reason},退出以免留下没人接管的进程\n`) } catch { /* fd 2 不可写也不影响退出 */ }
|
|
29
37
|
process.kill(process.pid, 'SIGTERM')
|
|
30
|
-
setTimeout(() => process.exit(1), LINK.
|
|
38
|
+
setTimeout(() => process.exit(1), LINK.exitGraceMs).unref()
|
|
31
39
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
// 这条计时器只兜「连接真的没建立」,不能当成握手截止时间:dsh 的同步启动阶段会把事件
|
|
41
|
+
// 循环占住几十秒,TCP 连接其实早已完成,只是 connect 回调排在定时器后面。让这一轮 poll
|
|
42
|
+
// 先跑完再判断——连上了就不算失去监管(见 #87)。
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
setImmediate(() => {
|
|
45
|
+
if (socket.readyState === 'open') return
|
|
46
|
+
fail('没有连上启动器(连接超时)')
|
|
47
|
+
})
|
|
48
|
+
}, LINK.connectTimeoutMs).unref()
|
|
49
|
+
socket.on('error', (error) => fail(`出错(${error?.code || error?.message || '连接失败'})`))
|
|
50
|
+
socket.on('close', () => fail('已断开(启动器退出或撤销了这次启动)'))
|
|
35
51
|
// 先排入握手帧,再包装输出:极快的启动可能在 TCP connect 回调之前就打印 ready。
|
|
36
52
|
socket.write(JSON.stringify({ secret: config.secret, pid: process.pid }) + '\n')
|
|
37
53
|
socket.on('connect', () => clearTimeout(timer))
|
|
38
54
|
socket.on('data', () => {
|
|
39
55
|
// 这里只接收启动器撤销本次启动的指令;晚到的继任进程也必须退出,不能复活已停止的实例。
|
|
40
|
-
fail()
|
|
56
|
+
fail('收到了撤销指令')
|
|
41
57
|
})
|
|
42
58
|
for (const stream of [process.stdout, process.stderr]) {
|
|
43
59
|
const write = stream.write
|
package/desktop.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 真正执行 Notification / dialog / clipboard 的代码在外壳里。
|
|
7
7
|
*
|
|
8
8
|
* 不许 import Electron,也不许 import shell/:核心和外壳是分开更新的两层,核心目录
|
|
9
|
-
*
|
|
9
|
+
* (用户目录的 core/current)里没有 shell/,外壳目录里也没有核心模块。
|
|
10
10
|
* 外壳侧另有 shell/desktop.mjs 做它自己的门控(通知开关、节流、Electron 对话框选项)。
|
|
11
11
|
*/
|
|
12
12
|
import { isAbsolute } from 'node:path'
|
package/instance-link.js
CHANGED
|
@@ -57,7 +57,9 @@ export async function createInstanceLink({ entry, profile, profileArgIndex = 0,
|
|
|
57
57
|
sockets.set(socket, null)
|
|
58
58
|
let pending = ''
|
|
59
59
|
let child
|
|
60
|
-
|
|
60
|
+
// 只清掉「连上却一直不发握手帧」的连接:握手帧在 preload 里立刻写出,但两边的同步启动
|
|
61
|
+
// 都可能把事件循环占住一阵,用短的墙钟超时在这里抢先断线会误杀正常启动(见 #87)。
|
|
62
|
+
socket.setTimeout(LINK.handshakeTimeoutMs, () => socket.destroy())
|
|
61
63
|
socket.on('error', () => {})
|
|
62
64
|
socket.on('close', () => sockets.delete(socket))
|
|
63
65
|
socket.on('data', chunk => {
|
package/package.json
CHANGED
package/platform.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join } from 'node:path'
|
|
1
|
+
import { dirname } from 'node:path'
|
|
3
2
|
import { fileURLToPath } from 'node:url'
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -20,7 +19,7 @@ export const IS_LINUX = process.platform === 'linux'
|
|
|
20
19
|
export const IN_ELECTRON = Boolean(process.versions.electron)
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
|
-
*
|
|
22
|
+
* 打包后的核心:跑在用户目录的 core/current(安装包里不再带核心)。
|
|
24
23
|
* 外壳(shell/main.mjs)加载核心前会设 DSH_CORE=1——核心不在 resources/app 里,
|
|
25
24
|
* 但路径和更新行为都要按安装包处理。
|
|
26
25
|
*/
|
|
@@ -33,16 +32,17 @@ export const NPM_MODE = process.env.DSH_NPM === '1'
|
|
|
33
32
|
* 拉子进程(dsh、npm、pnpm)用的 node。
|
|
34
33
|
*
|
|
35
34
|
* 普通情况下就是正在跑的这个 node(源码运行 = 系统 node)。Electron 外壳里没有单独的 node
|
|
36
|
-
* 可执行文件:Electron 本体就带着 Node(44
|
|
37
|
-
* 后按 node
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* 可执行文件:Electron 本体就带着 Node(Electron 44 是 24.21,满足 dsh 的 engines >=24.9),
|
|
36
|
+
* 子进程设 ELECTRON_RUN_AS_NODE=1 后按 node 跑,所以这里固定把 Electron 本体当 node 返回。
|
|
37
|
+
*
|
|
38
|
+
* 安装包里的 node/ 只有 npm/pnpm 和命令行 shim(node.cmd 转调 Electron),**不发 node.exe**。
|
|
39
|
+
* ≤0.1.19 的旧安装可能在 core 目录里留下 node.exe(v22.19,低于 dsh 的要求),就地升级后
|
|
40
|
+
* 这份残留不能被优先使用:旧判断看的是 `join(root,'node','node.exe')`,一旦命中就会让
|
|
41
|
+
* dsh 跑在过时的 Node 上(见 #89)。DSH_RUNTIME_NODE 仍可显式指定运行时(打包脚本/测试用)。
|
|
40
42
|
*/
|
|
41
|
-
export function runtimeNode(env = process.env,
|
|
43
|
+
export function runtimeNode(env = process.env, _root = ROOT, inElectron = IN_ELECTRON) {
|
|
42
44
|
if (!inElectron) return process.execPath
|
|
43
45
|
if (env.DSH_RUNTIME_NODE) return env.DSH_RUNTIME_NODE
|
|
44
|
-
const bundled = join(root, 'node', IS_WINDOWS ? 'node.exe' : 'node')
|
|
45
|
-
if (existsSync(bundled)) return bundled
|
|
46
46
|
return process.execPath
|
|
47
47
|
}
|
|
48
48
|
|
package/plugins.js
CHANGED
|
@@ -98,6 +98,24 @@ export function insertedIds(text) {
|
|
|
98
98
|
return ids
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* 已装包是不是一个可挂载进 `dsh.profile.bundles` 的 bundle。
|
|
103
|
+
*
|
|
104
|
+
* 只看磁盘上的声明,不为猜包名服务:`dsh` 字段(任何形状)或根目录的
|
|
105
|
+
* `cordis.patch.yml` 都算 bundle;纯依赖/客户端插件没有这两样,挂进层栈只会让
|
|
106
|
+
* dsh 启动时解析不到 bundle 而整棵插件树失败。
|
|
107
|
+
*/
|
|
108
|
+
export function packageLooksLikeBundle(profileDir, packageName) {
|
|
109
|
+
const pkgDir = join(profileDir, 'node_modules', packageName)
|
|
110
|
+
try {
|
|
111
|
+
const manifest = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
|
112
|
+
if (manifest?.dsh !== undefined) return true
|
|
113
|
+
} catch {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
return existsSync(join(pkgDir, 'cordis.patch.yml'))
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
/** 一个已安装插件包拥有的 loader 行:声明的 dsh.bundle.patch + 根目录 cordis.patch.yml。 */
|
|
102
120
|
export function packageRowIds(profileDir, packageName) {
|
|
103
121
|
const pkgDir = join(profileDir, 'node_modules', packageName)
|
|
@@ -151,11 +169,16 @@ export function listPlugins(profileDir) {
|
|
|
151
169
|
// 空 profile:返回空清单
|
|
152
170
|
}
|
|
153
171
|
const deps = Object.keys(manifest?.dependencies ?? {}).sort()
|
|
172
|
+
// 0.2.x 起层栈只挂载 dsh.profile.bundles 里的包:包还在依赖里、但被「安全启动」
|
|
173
|
+
// 摘出了清单时,它其实不会被加载,列表里必须显示成关闭(打开开关会重新挂回去,
|
|
174
|
+
// 见 setPluginEnabled)。客户端插件没有加载行,不参与挂载判断。
|
|
175
|
+
const bundles = Array.isArray(manifest?.dsh?.profile?.bundles) ? manifest.dsh.profile.bundles : []
|
|
154
176
|
const plugins = deps.map((name) => {
|
|
155
177
|
const ids = packageRowIds(profileDir, name)
|
|
156
178
|
const official = PROTECTED_PACKAGE_RE.test(name)
|
|
157
179
|
const marketOwned = ids.length > 0 && ids.every((id) => MARKET_ROW_RE.test(id))
|
|
158
180
|
const disabled = ids.some((id) => state.disables.includes(id))
|
|
181
|
+
const mounted = !ids.length || bundles.includes(name)
|
|
159
182
|
let toggleable = true
|
|
160
183
|
let reason = ''
|
|
161
184
|
if (!ids.length) {
|
|
@@ -169,7 +192,7 @@ export function listPlugins(profileDir) {
|
|
|
169
192
|
reason = '市场自管行,不提供开关'
|
|
170
193
|
}
|
|
171
194
|
const meta = packageMeta(profileDir, name)
|
|
172
|
-
return { name, version: meta.version, description: meta.description, ids, enabled: !disabled, toggleable, reason, official }
|
|
195
|
+
return { name, version: meta.version, description: meta.description, ids, enabled: mounted && !disabled, mounted, toggleable, reason, official }
|
|
173
196
|
})
|
|
174
197
|
return { profileDir, patchPath, plugins, disables: state.disables }
|
|
175
198
|
}
|
|
@@ -290,6 +313,26 @@ export function enableRowId(profileDir, rowId) {
|
|
|
290
313
|
return { ok: true, changed: true }
|
|
291
314
|
}
|
|
292
315
|
|
|
316
|
+
/**
|
|
317
|
+
* 一次性摘掉若干行的禁用块(卸载插件后清掉它留下的开关状态)。
|
|
318
|
+
*
|
|
319
|
+
* 不清理的话,补丁层会留下指向已卸载包的 `disabled: true`;这个包以后重装时
|
|
320
|
+
* 会莫名其妙地处于禁用状态,而用户早已不记得关过它。只删 `disabled: true`,
|
|
321
|
+
* 行里别的覆盖键留着;整行只剩 id/disabled 时按现有规则整块删掉。
|
|
322
|
+
*/
|
|
323
|
+
export function dropRowDisables(profileDir, rowIds) {
|
|
324
|
+
const ids = [...new Set(rowIds)].filter((id) => ROW_ID_RE.test(id) && !MARKET_ROW_RE.test(id))
|
|
325
|
+
if (!ids.length) return { ok: true, changed: false }
|
|
326
|
+
const patchPath = patchPathOf(profileDir)
|
|
327
|
+
const state = readPatchState(patchPath)
|
|
328
|
+
let text = state.text
|
|
329
|
+
for (const id of ids) text = removeDisableBlock(text, id)
|
|
330
|
+
if (text === state.text) return { ok: true, changed: false }
|
|
331
|
+
backupOnce(patchPath)
|
|
332
|
+
writeFileSync(patchPath, ensurePlaceholder(text))
|
|
333
|
+
return { ok: true, changed: true }
|
|
334
|
+
}
|
|
335
|
+
|
|
293
336
|
/**
|
|
294
337
|
* 强制启用/取消强制启用一条 loader 行(写/删 `disabled: false` 覆盖块)。
|
|
295
338
|
*
|
|
@@ -355,7 +398,7 @@ export function setPluginEnabled(profileDir, packageName, enabled) {
|
|
|
355
398
|
}
|
|
356
399
|
|
|
357
400
|
/** 从安装 spec(`pkg@1.2`、`@scope/pkg@1.2`、file:/git: 源)里取出纯包名;本地/远端源取不出,返回空串。 */
|
|
358
|
-
function bundleNameOf(spec) {
|
|
401
|
+
export function bundleNameOf(spec) {
|
|
359
402
|
const value = String(spec ?? '')
|
|
360
403
|
if (/^(?:file:|git\+|github:|https?:)/.test(value)) return ''
|
|
361
404
|
// 带 scope 的包名里那个 `@` 是名字的一部分(`@scope/pkg`),版本号才是名字之后那个 `@`。
|