@wanghaopeng1148/deskpet 2.0.2 → 2.0.4

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/README.md CHANGED
@@ -432,17 +432,25 @@ resources/ 皮肤、图标等随包资源
432
432
  - 通知通道:系统原生通知(osascript / PowerShell Toast / notify-send)+ 管理台页面内提示 + 微信。
433
433
  - **开机自启**:管理台「设置」页有开关(对应配置项 `system.autoStart`)。开启后各平台的落地方式:
434
434
 
435
- | 平台 | 写到哪里 |
436
- | --- | --- |
437
- | Windows | 启动文件夹 `DeskPet.cmd`(不依赖 reg / schtasks,受管控的机器也能用) |
438
- | macOS | `~/Library/LaunchAgents/DeskPet.plist` |
439
- | Linux | `~/.config/autostart/DeskPet.desktop`(XDG 标准) |
440
-
441
- 注册的命令统一是 `bin/deskpet.mjs --no-open` —— 它内部用当前 node 拉起服务(不依赖 PATH),
442
- 也不会在开机时弹浏览器。**想临时停用,直接删掉对应文件即可**,或把设置页开关关掉。
443
- 服务启动时也会按配置同步一次(关闭时会移除文件)。
444
-
445
- > 历史坑:本项目早期是 Electron 应用,自启是启动文件夹里的 `DeskPet.lnk`
446
- > (指向旧的 `pet\dist\DeskPet.exe`)。改成 Node 服务、目录改名后,那个快捷方式不会自动失效消失,
447
- > 结果就是「以为自启开着,实际每次开机拉起的是旧程序,新服务仍要手动启动」。
448
- > 所以开启自启时会**主动清理**同名的旧 `.lnk`。
435
+ | 平台 | 写到哪里 | 是否弹窗口 |
436
+ | --- | --- | --- |
437
+ | Windows | 启动文件夹 `DeskPet.vbs`(不依赖 reg / schtasks,受管控的机器也能用) | **不弹**(WScript 以隐藏窗口启动) |
438
+ | macOS | `~/Library/LaunchAgents/DeskPet.plist` | 不弹 |
439
+ | Linux | `~/.config/autostart/DeskPet.desktop`(XDG 标准) | 不弹 |
440
+
441
+ 注册的命令统一是 `bin/deskpet.mjs --no-open --log`:
442
+
443
+ - `--no-open` 不在开机时弹浏览器
444
+ - `--log` 把服务输出转存到 **`~/.deskpet/logs/service.log`**(超过 2MB 自动重开)。
445
+ Windows 用隐藏窗口启动后看不到控制台,日志必须有地方落 —— 排查启动失败就看它
446
+ - 入口内部用 `process.execPath` 拉起服务,不依赖 PATH 里有没有 node
447
+
448
+ **想临时停用**:删掉对应的自启文件即可,或在设置页把开关关掉。
449
+
450
+ > 历史坑 1:本项目早期是 Electron 应用,自启是启动文件夹里的 `DeskPet.lnk`
451
+ > (指向 `pet\dist\DeskPet.exe`)。改成 Node 服务、目录改名后,那个快捷方式不会自动消失,
452
+ > 于是「以为自启开着,实际每次开机拉起的是旧程序」。开启自启时会**主动清理**同名的旧 `.lnk`。
453
+ >
454
+ > 历史坑 2:中间一版用 `.cmd` + `start /min`,任务栏里**一直挂着一个最小化的 node.exe 窗口**。
455
+ > 现在是 `.vbs`(`WScript.Shell.Run(..., 0, False)`,窗口样式 0 = 完全隐藏),
456
+ > 写入时同样会清掉旧的 `.cmd`。文件用 UTF-16LE + BOM 编码,路径含中文也不会被按 ANSI 解析坏。
package/bin/deskpet.mjs CHANGED
@@ -12,10 +12,11 @@
12
12
  * npx deskpet 启动,并自动打开浏览器
13
13
  * npx deskpet --no-open 只启动、不开浏览器(SSH 远程 / 服务器场景)
14
14
  * npx deskpet --port 3211 指定端口(等价于设置 DESKPET_PORT)
15
+ * npx deskpet --log 输出转存到 <数据目录>/logs/service.log(供无窗口自启使用)
15
16
  * npx deskpet --check 只做环境自检,不启动服务
16
17
  */
17
18
  import { spawn, spawnSync } from 'node:child_process'
18
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
19
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
19
20
  import net from 'node:net'
20
21
  import { homedir } from 'node:os'
21
22
  import { dirname, join } from 'node:path'
@@ -99,11 +100,19 @@ const flagValue = (name) => {
99
100
  const cliPort = flagValue('--port') ?? process.env.DESKPET_PORT ?? null
100
101
  const checkOnly = hasFlag('--check')
101
102
  const shouldOpen = !hasFlag('--no-open')
103
+ /** 把服务输出转存到 <数据目录>/logs/service.log(自启时窗口是隐藏的,日志总得有地方看) */
104
+ const logToFile = hasFlag('--log')
102
105
 
103
106
  function log(msg) {
104
107
  process.stdout.write(`[deskpet] ${msg}\n`)
105
108
  }
106
109
 
110
+ /** 数据根目录(与 server/utils/paths.ts 的规则保持一致) */
111
+ function dataRoot() {
112
+ const env = process.env.DESKPET_HOME?.trim()
113
+ return env || join(homedir(), '.deskpet')
114
+ }
115
+
107
116
  /** 实际监听端口:命令行 > 环境变量 > 配置文件 > 默认 3210 */
108
117
  function resolvePort() {
109
118
  if (cliPort) {
@@ -112,7 +121,7 @@ function resolvePort() {
112
121
  console.error(`[deskpet] 端口无效:${cliPort}`)
113
122
  process.exit(1)
114
123
  }
115
- const home = process.env.DESKPET_HOME?.trim() || join(homedir(), '.deskpet')
124
+ const home = dataRoot()
116
125
  try {
117
126
  const s = JSON.parse(readFileSync(join(home, 'config', 'settings.json'), 'utf-8'))
118
127
  if (Number.isInteger(s?.server?.port) && s.server.port > 0) return s.server.port
@@ -187,12 +196,35 @@ if (checkOnly) {
187
196
 
188
197
  // ── 2. 启动后端 ───────────────────────────────────────────
189
198
  log(`启动服务(端口 ${port})…`)
199
+
200
+ /** 日志文件:超过 2MB 就重开一个,避免长期运行无限增长 */
201
+ function openLogStream() {
202
+ const file = join(dataRoot(), 'logs', 'service.log')
203
+ try {
204
+ mkdirSync(join(dataRoot(), 'logs'), { recursive: true })
205
+ const fresh = existsSync(file) && statSync(file).size < 2 * 1024 * 1024
206
+ const stream = createWriteStream(file, { flags: fresh ? 'a' : 'w' })
207
+ stream.write(`\n===== ${new Date().toISOString()} 启动(端口 ${port})=====\n`)
208
+ log(`日志文件:${file}`)
209
+ return stream
210
+ } catch (e) {
211
+ log(`日志文件不可写,输出将丢弃:${e?.message ?? e}`)
212
+ return null
213
+ }
214
+ }
215
+
216
+ const logStream = logToFile ? openLogStream() : null
190
217
  const child = spawn(process.execPath, [...server.args, server.entry], {
191
218
  cwd: pkgRoot,
192
- stdio: 'inherit',
219
+ stdio: logStream ? ['ignore', 'pipe', 'pipe'] : 'inherit',
193
220
  env: { ...process.env, DESKPET_PORT: String(port) }
194
221
  })
195
222
 
223
+ if (logStream) {
224
+ child.stdout.pipe(logStream)
225
+ child.stderr.pipe(logStream)
226
+ }
227
+
196
228
  process.on('SIGINT', () => child.kill('SIGINT'))
197
229
  process.on('SIGTERM', () => child.kill('SIGTERM'))
198
230
  child.on('exit', (code) => process.exit(code ?? 0))
@@ -220,12 +220,16 @@ async function main() {
220
220
  startHeartbeat();
221
221
  scheduler.start({ recover: instanceLock.acquired });
222
222
  // 开机自启:启动时应用 + 跟随设置变化
223
+ // removeWhenOff: false —— 启动时的同步只「确保开启时文件存在」,不删除。
224
+ // 否则一个用默认配置(autoStart=false)起来的实例(例如临时跑一次 npx,
225
+ // 或换了 DESKPET_HOME)会把主实例写在同一个启动文件夹里的自启文件删掉。
223
226
  let lastAutoStart = config.get().system.autoStart;
224
- void setAutoStart(lastAutoStart);
227
+ void setAutoStart(lastAutoStart, { removeWhenOff: false });
225
228
  config.on('changed', () => {
226
229
  const next = config.get().system.autoStart;
227
230
  if (next !== lastAutoStart) {
228
231
  lastAutoStart = next;
232
+ // 用户在设置页显式切换 → 关闭时应当真的移除文件
229
233
  void setAutoStart(next);
230
234
  }
231
235
  });
@@ -2,32 +2,42 @@
2
2
  * 开机自启
3
3
  *
4
4
  * 各平台实现:
5
- * Windows 启动文件夹放一个 .cmd —— 不依赖 reg / schtasks 命令,受管控的机器一样能用
5
+ * Windows 启动文件夹放一个 .vbs —— 不依赖 reg / schtasks 命令,受管控的机器一样能用;
6
+ * 用 WScript 以隐藏窗口(样式 0)启动,**任务栏不会出现 node.exe 窗口**
6
7
  * macOS ~/Library/LaunchAgents/<APP_ID>.plist
7
8
  * Linux ~/.config/autostart/<APP_ID>.desktop(XDG 标准,GNOME/KDE 都认)
8
9
  *
9
- * 启动命令统一走 `bin/deskpet.mjs --no-open`,理由:
10
+ * 启动命令统一走 `bin/deskpet.mjs --no-open --log`,理由:
10
11
  * · bin/ 在「源码运行」和「npm 包运行」两种形态下都存在(scripts/ 不进发布包)
11
- * · 它内部用 process.execPath 拉起 server/main.ts,不依赖 PATH 里有没有 node / npm
12
+ * · 它内部用 process.execPath 拉起后端,不依赖 PATH 里有没有 node / npm
12
13
  * · 脚本自己会按 package.json 推导根目录,所以不依赖工作目录
13
14
  * · --no-open 避免每次开机都弹一个浏览器
15
+ * · --log 把输出转存到文件:窗口隐藏后,出问题时总得有地方看日志
14
16
  *
15
17
  * 历史坑(说明为什么要主动清理旧文件):
16
18
  * 本项目早期是 Electron 应用,自启是安装包写在启动文件夹里的快捷方式
17
19
  * (DeskPet.lnk → pet\dist\DeskPet.exe)。后来改成纯 Node 服务、目录也从 pet 换成 pet2.0,
18
20
  * 那个旧快捷方式却没人清理 —— 结果用户以为自启开着,实际每次开机拉起的是旧程序,
19
- * 新服务仍然要手动启动。因此下面在写入自启时会**顺手删掉同名的旧 .lnk**。
21
+ * 新服务仍然要手动启动。再往后一版用 .cmd(start /min),会弹出一个最小化窗口。
22
+ * 因此下面写自启时会**顺手删掉同名的旧 .lnk 与 .cmd**,避免重复启动或误导排查。
20
23
  *
21
24
  * 仅在用户在设置中开启时写入;任何异常都只记录日志,不影响服务本身。
22
25
  */
23
26
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
24
27
  import { homedir } from 'node:os';
25
- import { dirname, join, resolve } from 'node:path';
26
- import { fileURLToPath } from 'node:url';
28
+ import { dirname, join } from 'node:path';
29
+ import { getPackageRoot } from "./paths.js";
27
30
  const APP_ID = 'DeskPet';
28
- /** 应用根目录(源码运行为项目根,npm 包运行为包根) */
31
+ /**
32
+ * 应用根目录。
33
+ *
34
+ * 用 getPackageRoot() 向上查找 package.json,而不是写死 `../..`:
35
+ * 源码运行时本模块在 `server/utils/`,编译产物在 `dist/node/server/utils/`,
36
+ * 距包根的层数不同 —— 写死层级会让**从 npm 安装版开启自启**生成一个指向
37
+ * 不存在路径的启动脚本(开机静默失败)。
38
+ */
29
39
  function appRoot() {
30
- return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
40
+ return getPackageRoot();
31
41
  }
32
42
  /** 开机要执行的 node 可执行文件与入口脚本 */
33
43
  function launch() {
@@ -38,28 +48,35 @@ function winStartupDir() {
38
48
  const appData = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming');
39
49
  return join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
40
50
  }
41
- function winCmdPath() {
42
- return join(winStartupDir(), `${APP_ID}.cmd`);
51
+ function winVbsPath() {
52
+ return join(winStartupDir(), `${APP_ID}.vbs`);
43
53
  }
44
- function winLegacyLnkPath() {
45
- return join(winStartupDir(), `${APP_ID}.lnk`);
54
+ /** 历史版本写过的自启文件:Electron 时代的快捷方式、上一版的批处理 */
55
+ function winLegacyPaths() {
56
+ return [join(winStartupDir(), `${APP_ID}.lnk`), join(winStartupDir(), `${APP_ID}.cmd`)];
46
57
  }
47
58
  /**
48
- * 生成自启批处理。
59
+ * 生成自启脚本(VBScript)。
60
+ *
61
+ * 为什么不是 .cmd:批处理启动的任何控制台程序都会**弹出一个窗口**,
62
+ * `start /min` 只能把它最小化 —— 任务栏里始终挂着一个 node.exe,很碍眼。
63
+ * 改用 WScript.Shell.Run 的第二参数(窗口样式)传 0,窗口**完全隐藏**:
64
+ * 0 = 隐藏窗口,False = 不等待其结束(否则 wscript 会一直挂着)
49
65
  *
50
- * 注意两点:
51
- * · 换行必须是 CRLF,注释用英文 —— cmd 按系统 ANSI 代码页解析批处理,
52
- * 写中文注释在中文 Windows 上容易乱码(虽然注释不影响执行,但很难看)
53
- * · `start /min` 让服务窗口最小化,不打扰用户,同时任务栏里能看到它在跑
66
+ * 编码用 UTF-16LE 带 BOM:wscript 默认按系统 ANSI 代码页读 .vbs,
67
+ * 若安装路径含中文(例如用户名是中文)会乱码成乱路径;带 BOM 则按 Unicode 解析。
68
+ *
69
+ * 隐藏窗口后看不到服务日志,所以启动命令带 `--log`:输出转存到
70
+ * <数据目录>/logs/service.log(见 bin/deskpet.mjs)。
54
71
  */
55
- function winCmdContent() {
72
+ function winVbsContent() {
56
73
  const { node, entry } = launch();
57
74
  return [
58
- '@echo off',
59
- 'rem DeskPet autostart - generated by the DeskPet dashboard.',
60
- 'rem Delete this file to disable autostart.',
61
- `cd /d "${appRoot()}"`,
62
- `start "${APP_ID}" /min "${node}" "${entry}" --no-open`,
75
+ "' DeskPet autostart - generated by the DeskPet dashboard.",
76
+ "' Delete this file to disable autostart.",
77
+ 'Set sh = CreateObject("WScript.Shell")',
78
+ `sh.CurrentDirectory = "${appRoot()}"`,
79
+ `sh.Run """${node}"" ""${entry}"" --no-open --log", 0, False`,
63
80
  ''
64
81
  ].join('\r\n');
65
82
  }
@@ -106,30 +123,33 @@ function linuxDesktopContent() {
106
123
  ''
107
124
  ].join('\n');
108
125
  }
109
- // ── 对外接口 ────────────────────────────────────────────────
110
126
  /** 开启 / 关闭开机自启,返回是否成功(失败原因会打印到日志) */
111
- export async function setAutoStart(on) {
127
+ export async function setAutoStart(on, opts = {}) {
128
+ const removeWhenOff = opts.removeWhenOff ?? true;
112
129
  try {
113
130
  if (process.platform === 'win32') {
114
131
  const dir = winStartupDir();
115
132
  mkdirSync(dir, { recursive: true });
116
- // 清掉 Electron 时代遗留的失效快捷方式(它指向 pet\dist\DeskPet.exe)
117
- const legacy = winLegacyLnkPath();
118
- if (existsSync(legacy)) {
133
+ // 清掉历史遗留:Electron 时代的 .lnk(指向已不存在的 pet\dist\DeskPet.exe)
134
+ // 与上一版的 .cmd(会弹窗口)。留着只会重复启动或误导排查。
135
+ for (const legacy of winLegacyPaths()) {
136
+ if (!existsSync(legacy))
137
+ continue;
119
138
  try {
120
139
  unlinkSync(legacy);
121
- console.log(`[autostart] 已清理旧版自启快捷方式: ${legacy}`);
140
+ console.log(`[autostart] 已清理旧版自启文件: ${legacy}`);
122
141
  }
123
142
  catch (err) {
124
- console.warn(`[autostart] 旧快捷方式清理失败(可手动删除): ${String(err)}`);
143
+ console.warn(`[autostart] 旧自启文件清理失败(可手动删除): ${String(err)}`);
125
144
  }
126
145
  }
127
- const file = winCmdPath();
146
+ const file = winVbsPath();
128
147
  if (on) {
129
- writeFileSync(file, winCmdContent(), 'utf-8');
130
- console.log(`[autostart] 开机自启已开启: ${file}`);
148
+ // utf16le + BOM:见 winVbsContent() 的说明(路径含中文时不会被 ANSI 解析搞坏)
149
+ writeFileSync(file, Buffer.from(`\ufeff${winVbsContent()}`, 'utf16le'));
150
+ console.log(`[autostart] 开机自启已开启(静默,无窗口): ${file}`);
131
151
  }
132
- else if (existsSync(file)) {
152
+ else if (removeWhenOff && existsSync(file)) {
133
153
  unlinkSync(file);
134
154
  console.log('[autostart] 开机自启已关闭');
135
155
  }
@@ -142,7 +162,7 @@ export async function setAutoStart(on) {
142
162
  writeFileSync(file, macPlistContent(), 'utf-8');
143
163
  console.log(`[autostart] 开机自启已开启: ${file}`);
144
164
  }
145
- else if (existsSync(file)) {
165
+ else if (removeWhenOff && existsSync(file)) {
146
166
  unlinkSync(file);
147
167
  console.log('[autostart] 开机自启已关闭');
148
168
  }
@@ -155,7 +175,7 @@ export async function setAutoStart(on) {
155
175
  writeFileSync(file, linuxDesktopContent(), 'utf-8');
156
176
  console.log(`[autostart] 开机自启已开启: ${file}`);
157
177
  }
158
- else if (existsSync(file)) {
178
+ else if (removeWhenOff && existsSync(file)) {
159
179
  unlinkSync(file);
160
180
  console.log('[autostart] 开机自启已关闭');
161
181
  }
@@ -172,8 +192,10 @@ export async function setAutoStart(on) {
172
192
  /** 查询当前是否已设置自启 */
173
193
  export function autoStartEnabled() {
174
194
  try {
175
- if (process.platform === 'win32')
176
- return existsSync(winCmdPath());
195
+ if (process.platform === 'win32') {
196
+ // .cmd 是上一版留下的形式,也算「已开启」,直到下次写入时被替换
197
+ return winLegacyPaths().some((p) => p.endsWith('.cmd') && existsSync(p)) || existsSync(winVbsPath());
198
+ }
177
199
  if (process.platform === 'darwin')
178
200
  return existsSync(macPlistPath());
179
201
  if (process.platform === 'linux')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wanghaopeng1148/deskpet",
3
3
  "productName": "DeskPet",
4
- "version": "2.0.2",
4
+ "version": "2.0.4",
5
5
  "description": "DeskPet 2.0 — 本机任务自动化服务 + Web 管理台 (Node + Express + Vue 3 + TypeScript)",
6
6
  "type": "module",
7
7
  "author": "whp",
package/server/main.ts CHANGED
@@ -239,12 +239,16 @@ async function main(): Promise<void> {
239
239
  scheduler.start({ recover: instanceLock.acquired })
240
240
 
241
241
  // 开机自启:启动时应用 + 跟随设置变化
242
+ // removeWhenOff: false —— 启动时的同步只「确保开启时文件存在」,不删除。
243
+ // 否则一个用默认配置(autoStart=false)起来的实例(例如临时跑一次 npx,
244
+ // 或换了 DESKPET_HOME)会把主实例写在同一个启动文件夹里的自启文件删掉。
242
245
  let lastAutoStart = config.get().system.autoStart
243
- void setAutoStart(lastAutoStart)
246
+ void setAutoStart(lastAutoStart, { removeWhenOff: false })
244
247
  config.on('changed', () => {
245
248
  const next = config.get().system.autoStart
246
249
  if (next !== lastAutoStart) {
247
250
  lastAutoStart = next
251
+ // 用户在设置页显式切换 → 关闭时应当真的移除文件
248
252
  void setAutoStart(next)
249
253
  }
250
254
  })
@@ -2,34 +2,44 @@
2
2
  * 开机自启
3
3
  *
4
4
  * 各平台实现:
5
- * Windows 启动文件夹放一个 .cmd —— 不依赖 reg / schtasks 命令,受管控的机器一样能用
5
+ * Windows 启动文件夹放一个 .vbs —— 不依赖 reg / schtasks 命令,受管控的机器一样能用;
6
+ * 用 WScript 以隐藏窗口(样式 0)启动,**任务栏不会出现 node.exe 窗口**
6
7
  * macOS ~/Library/LaunchAgents/<APP_ID>.plist
7
8
  * Linux ~/.config/autostart/<APP_ID>.desktop(XDG 标准,GNOME/KDE 都认)
8
9
  *
9
- * 启动命令统一走 `bin/deskpet.mjs --no-open`,理由:
10
+ * 启动命令统一走 `bin/deskpet.mjs --no-open --log`,理由:
10
11
  * · bin/ 在「源码运行」和「npm 包运行」两种形态下都存在(scripts/ 不进发布包)
11
- * · 它内部用 process.execPath 拉起 server/main.ts,不依赖 PATH 里有没有 node / npm
12
+ * · 它内部用 process.execPath 拉起后端,不依赖 PATH 里有没有 node / npm
12
13
  * · 脚本自己会按 package.json 推导根目录,所以不依赖工作目录
13
14
  * · --no-open 避免每次开机都弹一个浏览器
15
+ * · --log 把输出转存到文件:窗口隐藏后,出问题时总得有地方看日志
14
16
  *
15
17
  * 历史坑(说明为什么要主动清理旧文件):
16
18
  * 本项目早期是 Electron 应用,自启是安装包写在启动文件夹里的快捷方式
17
19
  * (DeskPet.lnk → pet\dist\DeskPet.exe)。后来改成纯 Node 服务、目录也从 pet 换成 pet2.0,
18
20
  * 那个旧快捷方式却没人清理 —— 结果用户以为自启开着,实际每次开机拉起的是旧程序,
19
- * 新服务仍然要手动启动。因此下面在写入自启时会**顺手删掉同名的旧 .lnk**。
21
+ * 新服务仍然要手动启动。再往后一版用 .cmd(start /min),会弹出一个最小化窗口。
22
+ * 因此下面写自启时会**顺手删掉同名的旧 .lnk 与 .cmd**,避免重复启动或误导排查。
20
23
  *
21
24
  * 仅在用户在设置中开启时写入;任何异常都只记录日志,不影响服务本身。
22
25
  */
23
26
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
24
27
  import { homedir } from 'node:os'
25
- import { dirname, join, resolve } from 'node:path'
26
- import { fileURLToPath } from 'node:url'
28
+ import { dirname, join } from 'node:path'
29
+ import { getPackageRoot } from './paths.ts'
27
30
 
28
31
  const APP_ID = 'DeskPet'
29
32
 
30
- /** 应用根目录(源码运行为项目根,npm 包运行为包根) */
33
+ /**
34
+ * 应用根目录。
35
+ *
36
+ * 用 getPackageRoot() 向上查找 package.json,而不是写死 `../..`:
37
+ * 源码运行时本模块在 `server/utils/`,编译产物在 `dist/node/server/utils/`,
38
+ * 距包根的层数不同 —— 写死层级会让**从 npm 安装版开启自启**生成一个指向
39
+ * 不存在路径的启动脚本(开机静默失败)。
40
+ */
31
41
  function appRoot(): string {
32
- return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
42
+ return getPackageRoot()
33
43
  }
34
44
 
35
45
  /** 开机要执行的 node 可执行文件与入口脚本 */
@@ -44,30 +54,37 @@ function winStartupDir(): string {
44
54
  return join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
45
55
  }
46
56
 
47
- function winCmdPath(): string {
48
- return join(winStartupDir(), `${APP_ID}.cmd`)
57
+ function winVbsPath(): string {
58
+ return join(winStartupDir(), `${APP_ID}.vbs`)
49
59
  }
50
60
 
51
- function winLegacyLnkPath(): string {
52
- return join(winStartupDir(), `${APP_ID}.lnk`)
61
+ /** 历史版本写过的自启文件:Electron 时代的快捷方式、上一版的批处理 */
62
+ function winLegacyPaths(): string[] {
63
+ return [join(winStartupDir(), `${APP_ID}.lnk`), join(winStartupDir(), `${APP_ID}.cmd`)]
53
64
  }
54
65
 
55
66
  /**
56
- * 生成自启批处理。
67
+ * 生成自启脚本(VBScript)。
68
+ *
69
+ * 为什么不是 .cmd:批处理启动的任何控制台程序都会**弹出一个窗口**,
70
+ * `start /min` 只能把它最小化 —— 任务栏里始终挂着一个 node.exe,很碍眼。
71
+ * 改用 WScript.Shell.Run 的第二参数(窗口样式)传 0,窗口**完全隐藏**:
72
+ * 0 = 隐藏窗口,False = 不等待其结束(否则 wscript 会一直挂着)
73
+ *
74
+ * 编码用 UTF-16LE 带 BOM:wscript 默认按系统 ANSI 代码页读 .vbs,
75
+ * 若安装路径含中文(例如用户名是中文)会乱码成乱路径;带 BOM 则按 Unicode 解析。
57
76
  *
58
- * 注意两点:
59
- * · 换行必须是 CRLF,注释用英文 —— cmd 按系统 ANSI 代码页解析批处理,
60
- * 写中文注释在中文 Windows 上容易乱码(虽然注释不影响执行,但很难看)
61
- * · 用 `start /min` 让服务窗口最小化,不打扰用户,同时任务栏里能看到它在跑
77
+ * 隐藏窗口后看不到服务日志,所以启动命令带 `--log`:输出转存到
78
+ * <数据目录>/logs/service.log(见 bin/deskpet.mjs)。
62
79
  */
63
- function winCmdContent(): string {
80
+ function winVbsContent(): string {
64
81
  const { node, entry } = launch()
65
82
  return [
66
- '@echo off',
67
- 'rem DeskPet autostart - generated by the DeskPet dashboard.',
68
- 'rem Delete this file to disable autostart.',
69
- `cd /d "${appRoot()}"`,
70
- `start "${APP_ID}" /min "${node}" "${entry}" --no-open`,
83
+ "' DeskPet autostart - generated by the DeskPet dashboard.",
84
+ "' Delete this file to disable autostart.",
85
+ 'Set sh = CreateObject("WScript.Shell")',
86
+ `sh.CurrentDirectory = "${appRoot()}"`,
87
+ `sh.Run """${node}"" ""${entry}"" --no-open --log", 0, False`,
71
88
  ''
72
89
  ].join('\r\n')
73
90
  }
@@ -123,29 +140,45 @@ function linuxDesktopContent(): string {
123
140
 
124
141
  // ── 对外接口 ────────────────────────────────────────────────
125
142
 
143
+ export interface AutoStartOptions {
144
+ /**
145
+ * 配置为「关闭」时是否删除自启文件(默认 true)。
146
+ *
147
+ * 启动时的同步必须传 false:多个实例可能共用同一个启动文件夹,
148
+ * 一个以默认配置(autoStart=false)起来的实例不该把别人的自启文件删掉。
149
+ * 这个坑很隐蔽 —— 临时跑一次 `npx deskpet`(数据目录是全新的默认配置)
150
+ * 就会把主实例的开机自启悄悄弄没,之后每次开机都得手动启动。
151
+ * 关闭自启只应由「设置页里显式关掉开关」这个动作来执行。
152
+ */
153
+ removeWhenOff?: boolean
154
+ }
155
+
126
156
  /** 开启 / 关闭开机自启,返回是否成功(失败原因会打印到日志) */
127
- export async function setAutoStart(on: boolean): Promise<boolean> {
157
+ export async function setAutoStart(on: boolean, opts: AutoStartOptions = {}): Promise<boolean> {
158
+ const removeWhenOff = opts.removeWhenOff ?? true
128
159
  try {
129
160
  if (process.platform === 'win32') {
130
161
  const dir = winStartupDir()
131
162
  mkdirSync(dir, { recursive: true })
132
163
 
133
- // 清掉 Electron 时代遗留的失效快捷方式(它指向 pet\dist\DeskPet.exe)
134
- const legacy = winLegacyLnkPath()
135
- if (existsSync(legacy)) {
164
+ // 清掉历史遗留:Electron 时代的 .lnk(指向已不存在的 pet\dist\DeskPet.exe)
165
+ // 与上一版的 .cmd(会弹窗口)。留着只会重复启动或误导排查。
166
+ for (const legacy of winLegacyPaths()) {
167
+ if (!existsSync(legacy)) continue
136
168
  try {
137
169
  unlinkSync(legacy)
138
- console.log(`[autostart] 已清理旧版自启快捷方式: ${legacy}`)
170
+ console.log(`[autostart] 已清理旧版自启文件: ${legacy}`)
139
171
  } catch (err) {
140
- console.warn(`[autostart] 旧快捷方式清理失败(可手动删除): ${String(err)}`)
172
+ console.warn(`[autostart] 旧自启文件清理失败(可手动删除): ${String(err)}`)
141
173
  }
142
174
  }
143
175
 
144
- const file = winCmdPath()
176
+ const file = winVbsPath()
145
177
  if (on) {
146
- writeFileSync(file, winCmdContent(), 'utf-8')
147
- console.log(`[autostart] 开机自启已开启: ${file}`)
148
- } else if (existsSync(file)) {
178
+ // utf16le + BOM:见 winVbsContent() 的说明(路径含中文时不会被 ANSI 解析搞坏)
179
+ writeFileSync(file, Buffer.from(`\ufeff${winVbsContent()}`, 'utf16le'))
180
+ console.log(`[autostart] 开机自启已开启(静默,无窗口): ${file}`)
181
+ } else if (removeWhenOff && existsSync(file)) {
149
182
  unlinkSync(file)
150
183
  console.log('[autostart] 开机自启已关闭')
151
184
  }
@@ -158,7 +191,7 @@ export async function setAutoStart(on: boolean): Promise<boolean> {
158
191
  mkdirSync(dirname(file), { recursive: true })
159
192
  writeFileSync(file, macPlistContent(), 'utf-8')
160
193
  console.log(`[autostart] 开机自启已开启: ${file}`)
161
- } else if (existsSync(file)) {
194
+ } else if (removeWhenOff && existsSync(file)) {
162
195
  unlinkSync(file)
163
196
  console.log('[autostart] 开机自启已关闭')
164
197
  }
@@ -171,7 +204,7 @@ export async function setAutoStart(on: boolean): Promise<boolean> {
171
204
  mkdirSync(dirname(file), { recursive: true })
172
205
  writeFileSync(file, linuxDesktopContent(), 'utf-8')
173
206
  console.log(`[autostart] 开机自启已开启: ${file}`)
174
- } else if (existsSync(file)) {
207
+ } else if (removeWhenOff && existsSync(file)) {
175
208
  unlinkSync(file)
176
209
  console.log('[autostart] 开机自启已关闭')
177
210
  }
@@ -189,7 +222,10 @@ export async function setAutoStart(on: boolean): Promise<boolean> {
189
222
  /** 查询当前是否已设置自启 */
190
223
  export function autoStartEnabled(): boolean {
191
224
  try {
192
- if (process.platform === 'win32') return existsSync(winCmdPath())
225
+ if (process.platform === 'win32') {
226
+ // .cmd 是上一版留下的形式,也算「已开启」,直到下次写入时被替换
227
+ return winLegacyPaths().some((p) => p.endsWith('.cmd') && existsSync(p)) || existsSync(winVbsPath())
228
+ }
193
229
  if (process.platform === 'darwin') return existsSync(macPlistPath())
194
230
  if (process.platform === 'linux') return existsSync(linuxDesktopPath())
195
231
  } catch {