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 CHANGED
@@ -3,7 +3,7 @@
3
3
  // 流程:等父进程退出 → 备份旧核心目录 → 换上新解压好的暂存目录 → 重启外壳 →
4
4
  // 等新版本写出启动标记;没等到就回滚到备份再重启。所有结果写进 update.log。
5
5
  //
6
- // 只替换核心目录(安装包的 resources/core 或用户目录的 core/current),不碰 DSH.exe 和
6
+ // 只替换核心目录(用户目录的 core/current;源码/npm 模式下是别的目录),不碰 DSH.exe 和
7
7
  // Electron 运行时(所以不受文件锁影响)。参数全部走命令行;这里只依赖 Node 内置模块,
8
8
  // 脚本可以放在临时目录里跑。
9
9
 
@@ -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, ready: /dsh web:\s+(https?:\/\/[^\s]+)/,
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.connectTimeoutMs).unref()
38
+ setTimeout(() => process.exit(1), LINK.exitGraceMs).unref()
31
39
  }
32
- const timer = setTimeout(fail, LINK.connectTimeoutMs).unref()
33
- socket.on('error', fail)
34
- socket.on('close', fail)
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
- * (resources/core 或用户目录 core/current)里没有 shell/,外壳目录里也没有核心模块。
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
- socket.setTimeout(LINK.connectTimeoutMs, () => socket.destroy())
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-x-core",
3
3
  "productName": "DSH",
4
- "version": "0.1.25",
4
+ "version": "0.1.26-rc.2",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "main": "server.js",
package/platform.js CHANGED
@@ -1,5 +1,4 @@
1
- import { existsSync } from 'node:fs'
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
- * 打包后的核心:跑在安装包里(resources/core)或用户目录的 core/current。
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 起是 Node 24),子进程设 ELECTRON_RUN_AS_NODE=1
37
- * 后按 node 跑。所以这里把 Electron 本体当 node 返回,环境变量由 RUNTIME_NODE_ENV 补。
38
- * (0.1.19 及以前的安装包在 node/ 下自带过一份 node,保留 existsSync 判断兼容它们。)
39
- * DSH_RUNTIME_NODE 可以显式指定(打包脚本和开发测试用)。
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, root = ROOT, inElectron = IN_ELECTRON) {
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`),版本号才是名字之后那个 `@`。