@shawnxiaoyang/dsh-tray-restart 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shawn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,108 @@
1
- # Temporary Holding Version
1
+ # @shawnxiaoyang/dsh-tray-restart
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 让桌面端托盘的「重启 DeepSeek Harness」项在每次自动升级后自动回来。
4
+
5
+ > **它会修改官方安装包。** 本插件会在你退出应用后,把 `resources/app.asar` 换成一份
6
+ > 打过补丁的副本(原档备份为 `app.asar.heal.bak`)。这属于修改已安装程序,可能触发杀软
7
+ > 告警;如果上游开启了归档完整性校验,插件会拒绝动手(见下文「安全闸门」)。
8
+
9
+ ## 它解决什么问题
10
+
11
+ 桌面端(`DeepSeek Harness.exe`)的托盘右键菜单写在 Electron 主进程里,也就是打包进
12
+ `resources/app.asar` 的 `lib/main.js`。nightly 更新是**整包替换**:升级后 `app.asar`
13
+ 被换回官方原版,托盘菜单里就只剩「打开」「退出」。
14
+
15
+ 托盘菜单属于主进程,插件体系碰不到它(主进程的 Tray、没有 restart 类 IPC、渲染进程
16
+ 发来的消息还要过 `dsh-app:` 协议校验)。所以本插件不试图"添加菜单项",而是**修好那个
17
+ 装着菜单项的归档文件**——在 Windows 唯一允许替换它的时刻:应用退出之后。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ dsh plugin --profile desktop add @shawnxiaoyang/dsh-tray-restart
23
+ ```
24
+
25
+ 装好后重启一次应用即可。**只在 Windows 桌面端生效**:macOS / Linux 的桌面端、以及终端里
26
+ `dsh web` 起的 CLI 形态都没有这套托盘,插件会报告自己空闲并什么都不做。
27
+
28
+ ## 工作方式
29
+
30
+ 1. **发现**:宿主进程是 Electron 主进程的直接子进程,`process.execPath` 和
31
+ `process.resourcesPath` 就是应用自己的。读 `resources/app.asar` 里的 `lib/main.js`
32
+ 就能判断补丁还在不在。
33
+ 2. **暂存**:一个子进程在原归档旁边重建出打过补丁的 `app.asar.heal.new`。它**从不碰**
34
+ `app.asar`;任何一处锚点对不上就整体放弃,不产出半成品。
35
+ 3. **换包**:一个分离的守护进程等 Electron 主进程退出,再核对归档仍是当初构建补丁的那
36
+ 一份(哈希不符说明升级换过了,直接丢弃暂存包,绝不覆盖新版本),然后才把暂存包写进去。
37
+ 写完校验,失败就从备份还原。
38
+
39
+ `/restart` 补上运行中的一环:暂存 → 布防 → 由守护进程停掉应用并重新拉起。
40
+
41
+ CLI 形态的 Harness(终端里 `dsh web`)没有 Electron 外壳,插件会报告自己空闲并什么都不做。
42
+
43
+ ## 安全闸门
44
+
45
+ 补丁改的是官方归档,所以有两道闸门在写之前就拦住:
46
+
47
+ 1. **归档完整性校验**(Electron fuse `EnableEmbeddedAsarIntegrityValidation`)。开启时
48
+ Electron 会拿头部记录逐条校验打包条目,重建的归档必然对不上签名版本,结果是**应用
49
+ 退回默认 Electron 窗口、整个用不了**。插件因此先读可执行文件里的 fuse 位:只要不是
50
+ 明确关闭,就一律拒绝写入(读不到也算不安全)。当前桌面端 0.2.2-rc.3 是关闭的。
51
+ 2. **锚点唯一性**。每处改动都要求目标文本在 `lib/main.js` 里恰好出现一次;官方改写结构
52
+ 后会报 `anchor-failed` 并指出是哪一处,不会产出坏包。
53
+
54
+ 设置页会把这两件事的状态直接显示出来。
55
+
56
+ ## 使用
57
+
58
+ 正常使用时不需要做任何事:升级后第一次启动,插件在 4 秒后重建暂存包,等你下次正常退出
59
+ 应用时静默换包,再下次启动托盘项就回来了。
60
+
61
+ 想立刻生效,在会话里输入:
62
+
63
+ ```
64
+ /restart
65
+ ```
66
+
67
+ 它会重启应用,并顺带把托盘「重启」项修回来。
68
+
69
+ ## 设置页
70
+
71
+ 插件管理里这一行的设置页是只读的,显示:补丁是否在位、桌面端版本、`lib/main.js` 字节数、
72
+ 暂存包是否备好、重建用的运行时、归档完整性校验是否开启,以及最近 40 行日志。它通过宿主
73
+ 的只读回环路由 `/__tray-restart/status` 取数(非回环、跨站、DNS rebinding 一律 403),
74
+ 页面本身没有任何写入路径。
75
+
76
+ ## 配置
77
+
78
+ | 键 | 默认 | 含义 |
79
+ | --- | --- | --- |
80
+ | `enabled` | `true` | 启用本插件 |
81
+ | `autoHeal` | `true` | 启动时若发现补丁丢失,就在后台重建并暂存 |
82
+ | `restartCommand` | `true` | 注册 `/restart` 命令 |
83
+
84
+ ## 状态与日志
85
+
86
+ - 日志:`%LOCALAPPDATA%\dsh-tray-restart\heal.log`
87
+ - 暂存包:`<resources>\app.asar.heal.new`
88
+ - 换包备份:`<resources>\app.asar.heal.bak`
89
+ - 守护进程锁:`<resources>\app.asar.heal.lock`
90
+
91
+ ## 边界与已知代价
92
+
93
+ - 补丁依赖 `lib/main.js` 里的若干锚点文本。新版桌面端改写了这些位置时,插件会报告
94
+ `anchor-failed` 并给出是哪一处,而不是产出一个坏包。把那条消息发出来即可适配。
95
+ - 换包发生在应用退出之后,因此**升级后的第一次退出**才会真正生效,不是升级瞬间。
96
+ - 换包用"覆盖写"而不是"改名":Windows 会拒绝给仍被映射的大文件改名,但允许覆盖写。
97
+ 代价是归档有一瞬间是半写状态,因此备份先写好并校验,失败就回滚。
98
+ - 暂存包约 200 MB,构建耗时 1–2 秒,退出时会占一点磁盘 IO。
99
+ - 需要系统里有一个 Node.js 来跑重建子进程(找不到时回退到应用自带的 Electron node 模式)。
100
+
101
+ ## 卸载
102
+
103
+ 从 profile 里移除本插件后,`resources/app.asar` 会保持最后一次换包后的样子(仍是打过
104
+ 补丁的归档)。想恢复官方原版,用 `app.asar.heal.bak` 覆盖回去,或者重装一次桌面端。
105
+
106
+ ## 许可
107
+
108
+ MIT
@@ -0,0 +1,6 @@
1
+ # @shawnxiaoyang/dsh-tray-restart bundle patch: one host row over the Desktop profile.
2
+ # The row name resolves from the profile's own node_modules where
3
+ # `dsh plugin --profile <profile> add <path>` installed this package.
4
+ - insert:
5
+ - id: tray-restart
6
+ name: '@shawnxiaoyang/dsh-tray-restart'
package/lib/asar.mjs ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Minimal, read-and-rewrite ASAR support for the tray-restart patch.
3
+ *
4
+ * Only what this plugin needs: parse the two pickle headers, resolve one entry,
5
+ * and rebuild the archive after `lib/main.js` changes. The rebuild strategy is
6
+ * the one the shipped archives tolerate — the patched module is appended after
7
+ * the original data region (4-byte aligned), its entry offset/size/integrity are
8
+ * updated, and the header JSON is re-emitted with space padding so the data
9
+ * region keeps starting on a 4-byte boundary. Every other entry keeps its exact
10
+ * offset, so its bytes and hash are untouched.
11
+ *
12
+ * The header layout, verified against the real 0.2.2-rc.3 archive:
13
+ *
14
+ * [u32 4][u32 headerSize] size pickle, 8 bytes
15
+ * [u32 payloadLength][u32 jsonLength][json] header pickle, headerSize bytes
16
+ * <data region> entries, offsets relative to it
17
+ *
18
+ * with `payloadLength === headerSize - 4` and `dataStart === align4(16 + jsonLength)`.
19
+ *
20
+ * @module dsh-tray-restart/asar
21
+ */
22
+
23
+ import { createHash } from 'node:crypto'
24
+ import { closeSync, fstatSync, openSync, readFileSync, readSync } from 'node:fs'
25
+
26
+ /** Round up to the next multiple of four. */
27
+ export const align4 = (n) => n + ((4 - (n % 4)) % 4)
28
+
29
+ /**
30
+ * Run `fn` with Electron's asar shim disabled.
31
+ *
32
+ * Inside the Host process, `resources/app.asar` is treated as a directory, so a
33
+ * plain `readFileSync` on the archive itself fails with ENOENT. The shim is a
34
+ * process-wide switch, hence the save/restore: other plugins keep loading their
35
+ * own modules from the archive while this plugin reads it.
36
+ *
37
+ * @param fn - synchronous work that must see real file bytes.
38
+ * @returns whatever `fn` returned.
39
+ */
40
+ export function withNoAsar(fn) {
41
+ const previous = process.noAsar
42
+ process.noAsar = true
43
+ try {
44
+ return fn()
45
+ } finally {
46
+ process.noAsar = previous
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Parse and validate the header of an archive on disk, reading only the header.
52
+ *
53
+ * @param file - absolute archive path.
54
+ * @returns header JSON plus the geometry needed to resolve entries.
55
+ */
56
+ export function readArchiveHeader(file) {
57
+ const fd = openSync(file, 'r')
58
+ try {
59
+ const size = fstatSync(fd).size
60
+ const head = Buffer.alloc(16)
61
+ if (readSync(fd, head, 0, 16, 0) < 16) throw new Error('asar: file is shorter than its header')
62
+ const pickle = head.readUInt32LE(0)
63
+ if (pickle !== 4) throw new Error(`asar: unexpected size pickle ${pickle}`)
64
+ const headerSize = head.readUInt32LE(4)
65
+ const payloadLength = head.readUInt32LE(8)
66
+ const jsonLength = head.readUInt32LE(12)
67
+ if (payloadLength !== headerSize - 4) {
68
+ throw new Error(`asar: header pickle payload ${payloadLength} does not fill ${headerSize - 4}`)
69
+ }
70
+ if (jsonLength + 4 > payloadLength) throw new Error(`asar: json ${jsonLength} overflows payload ${payloadLength}`)
71
+ const dataStart = 8 + headerSize
72
+ if (dataStart !== align4(16 + jsonLength)) {
73
+ throw new Error(`asar: unexpected header layout (${dataStart} vs ${align4(16 + jsonLength)})`)
74
+ }
75
+ const json = Buffer.alloc(jsonLength)
76
+ if (readSync(fd, json, 0, jsonLength, 16) < jsonLength) throw new Error('asar: header json is truncated')
77
+ return { header: JSON.parse(json.toString('utf8')), dataStart, bytes: size, headerSize, jsonLength }
78
+ } finally {
79
+ closeSync(fd)
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Parse an archive already in memory.
85
+ *
86
+ * @param buffer - complete archive bytes.
87
+ * @returns the same shape as {@link readArchiveHeader}, plus the buffer.
88
+ */
89
+ export function readArchiveBuffer(buffer) {
90
+ const headerSize = buffer.readUInt32LE(4)
91
+ const payloadLength = buffer.readUInt32LE(8)
92
+ const jsonLength = buffer.readUInt32LE(12)
93
+ if (payloadLength !== headerSize - 4) {
94
+ throw new Error(`asar: header pickle payload ${payloadLength} does not fill ${headerSize - 4}`)
95
+ }
96
+ if (jsonLength + 4 > payloadLength) throw new Error(`asar: json ${jsonLength} overflows payload ${payloadLength}`)
97
+ const dataStart = 8 + headerSize
98
+ if (dataStart !== align4(16 + jsonLength)) {
99
+ throw new Error(`asar: unexpected header layout (${dataStart} vs ${align4(16 + jsonLength)})`)
100
+ }
101
+ const header = JSON.parse(buffer.subarray(16, 16 + jsonLength).toString('utf8'))
102
+ return { buffer, header, dataStart, bytes: buffer.length, headerSize, jsonLength }
103
+ }
104
+
105
+ /** Read the complete archive bytes. Callers wrap this in {@link withNoAsar}. */
106
+ export function readArchiveFull(file) {
107
+ return readArchiveBuffer(readFileSync(file))
108
+ }
109
+
110
+ /**
111
+ * Resolve one entry node by slash-separated path.
112
+ *
113
+ * @param header - parsed header JSON.
114
+ * @param path - entry path such as `lib/main.js`.
115
+ * @returns the entry node.
116
+ */
117
+ export function entryOf(header, path) {
118
+ let node = header
119
+ for (const segment of path.split('/').filter(Boolean)) {
120
+ node = node.files?.[segment]
121
+ if (node === undefined || node === null) throw new Error(`asar: missing entry ${path}`)
122
+ }
123
+ return node
124
+ }
125
+
126
+ /**
127
+ * Read one entry's bytes from an archive on disk.
128
+ *
129
+ * @param file - archive path.
130
+ * @param archive - result of {@link readArchiveHeader}.
131
+ * @param path - entry path.
132
+ * @returns the entry bytes.
133
+ */
134
+ export function readEntryBytes(file, archive, path) {
135
+ const entry = entryOf(archive.header, path)
136
+ const start = archive.dataStart + Number(entry.offset)
137
+ const size = Number(entry.size)
138
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(size) || start + size > archive.bytes) {
139
+ throw new Error(`asar: entry ${path} lies outside the archive`)
140
+ }
141
+ const fd = openSync(file, 'r')
142
+ try {
143
+ const buffer = Buffer.alloc(size)
144
+ if (readSync(fd, buffer, 0, size, start) < size) throw new Error(`asar: entry ${path} is truncated`)
145
+ return buffer
146
+ } finally {
147
+ closeSync(fd)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Read one entry's bytes from an archive already in memory.
153
+ *
154
+ * @param archive - result of {@link readArchiveBuffer}.
155
+ * @param path - entry path.
156
+ * @returns the entry bytes.
157
+ */
158
+ export function readEntryFromBuffer(archive, path) {
159
+ const entry = entryOf(archive.header, path)
160
+ const start = archive.dataStart + Number(entry.offset)
161
+ const size = Number(entry.size)
162
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(size) || start + size > archive.bytes) {
163
+ throw new Error(`asar: entry ${path} lies outside the archive`)
164
+ }
165
+ return archive.buffer.subarray(start, start + size)
166
+ }
167
+
168
+ /**
169
+ * Produce a new archive whose only difference is `lib/main.js`.
170
+ *
171
+ * @param source - result of {@link readArchiveFull}.
172
+ * @param patchedMain - replacement module bytes.
173
+ * @returns the complete new archive.
174
+ */
175
+ export function patchArchiveBuffer(source, patchedMain) {
176
+ const header = structuredClone(source.header)
177
+ const originalDataLength = source.buffer.length - source.dataStart
178
+ const alignedOffset = align4(originalDataLength)
179
+
180
+ const mainEntry = entryOf(header, 'lib/main.js')
181
+ mainEntry.offset = String(alignedOffset)
182
+ mainEntry.size = patchedMain.length
183
+ mainEntry.integrity = {
184
+ algorithm: 'SHA256',
185
+ hash: sha256(patchedMain),
186
+ blockSize: mainEntry.integrity?.blockSize ?? 4194304,
187
+ blocks: [sha256(patchedMain)],
188
+ }
189
+
190
+ let json = Buffer.from(JSON.stringify(header), 'utf8')
191
+ const newJsonLength = align4(json.length + 8) - 8
192
+ json = Buffer.concat([json, Buffer.alloc(newJsonLength - json.length, 0x20)])
193
+ const newHeaderSize = newJsonLength + 8
194
+
195
+ const head = Buffer.alloc(16)
196
+ head.writeUInt32LE(4, 0)
197
+ head.writeUInt32LE(newHeaderSize, 4)
198
+ head.writeUInt32LE(newJsonLength + 4, 8)
199
+ head.writeUInt32LE(newJsonLength, 12)
200
+
201
+ const padding = Buffer.alloc(alignedOffset - originalDataLength)
202
+ return Buffer.concat([head, json, source.buffer.subarray(source.dataStart), padding, patchedMain])
203
+ }
204
+
205
+ /**
206
+ * Verify a produced archive against its source without trusting the writer.
207
+ *
208
+ * Checks the header geometry, that the unchanged data region is byte-identical,
209
+ * that `lib/main.js` is exactly the patched module with a matching integrity
210
+ * hash, and that every entry stays inside the file.
211
+ *
212
+ * @param output - produced archive bytes.
213
+ * @param source - source archive (result of {@link readArchiveFull}).
214
+ * @param patchedMain - expected module bytes.
215
+ * @returns a short report of what was checked.
216
+ */
217
+ export function verifyArchiveBuffer(output, source, patchedMain) {
218
+ const archive = readArchiveBuffer(output)
219
+ const originalDataLength = source.buffer.length - source.dataStart
220
+
221
+ const prefix = output.subarray(archive.dataStart, archive.dataStart + originalDataLength)
222
+ const originalPrefix = source.buffer.subarray(source.dataStart)
223
+ if (!prefix.equals(originalPrefix)) throw new Error('verify: the unchanged data region differs from the source')
224
+
225
+ const main = readEntryFromBuffer(archive, 'lib/main.js')
226
+ if (!main.equals(patchedMain)) throw new Error('verify: lib/main.js bytes differ from the patched module')
227
+ const mainEntry = entryOf(archive.header, 'lib/main.js')
228
+ if (mainEntry.integrity?.hash !== sha256(main)) throw new Error('verify: lib/main.js integrity hash mismatch')
229
+
230
+ for (const path of ['package.json', 'lib/preload-app.cjs', 'renderer/welcome.html', 'dsh/package.json']) {
231
+ const before = readEntryFromBuffer(source, path)
232
+ const after = readEntryFromBuffer(archive, path)
233
+ if (!before.equals(after)) throw new Error(`verify: ${path} bytes changed`)
234
+ }
235
+
236
+ let checked = 0
237
+ const walk = (node) => {
238
+ if (Array.isArray(node.files) === true) {
239
+ for (const child of node.files) walk(child)
240
+ return
241
+ }
242
+ if (node.files !== undefined && node.files !== null) {
243
+ for (const child of Object.values(node.files)) walk(child)
244
+ return
245
+ }
246
+ if (node.unpacked === true) return
247
+ const start = archive.dataStart + Number(node.offset)
248
+ const size = Number(node.size)
249
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(size) || start + size > archive.bytes) {
250
+ throw new Error(`verify: entry at ${start} (+${size}) lies outside the archive`)
251
+ }
252
+ checked += 1
253
+ }
254
+ walk(archive.header)
255
+
256
+ return { archiveBytes: output.length, entries: checked, mainBytes: patchedMain.length, mainSha256: sha256(main) }
257
+ }
258
+
259
+ /** Hex SHA-256 of a buffer. */
260
+ export function sha256(buffer) {
261
+ return createHash('sha256').update(buffer).digest('hex')
262
+ }