@wanghaopeng1148/deskpet 2.0.3 → 2.0.5

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
@@ -146,13 +155,16 @@ function waitPort(port, host = '127.0.0.1', timeoutMs = 30000) {
146
155
  }
147
156
 
148
157
  function openUrl(url) {
158
+ // windowsHide 一并加上:这三个都是控制台程序(cmd)或会转交控制台,
159
+ // stdio 又是 ignore + detached,不加就会给浏览器那一跳闪一个黑窗体
160
+ const opts = { stdio: 'ignore', detached: true, windowsHide: true }
149
161
  try {
150
162
  if (isWin) {
151
- spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref()
163
+ spawn('cmd', ['/c', 'start', '', url], opts).unref()
152
164
  } else if (process.platform === 'darwin') {
153
- spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
165
+ spawn('open', [url], opts).unref()
154
166
  } else {
155
- spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
167
+ spawn('xdg-open', [url], opts).unref()
156
168
  }
157
169
  } catch {
158
170
  /* 打不开就算了:URL 已经打印出来,用户可自行复制 */
@@ -187,12 +199,45 @@ if (checkOnly) {
187
199
 
188
200
  // ── 2. 启动后端 ───────────────────────────────────────────
189
201
  log(`启动服务(端口 ${port})…`)
202
+
203
+ /** 日志文件:超过 2MB 就重开一个,避免长期运行无限增长 */
204
+ function openLogStream() {
205
+ const file = join(dataRoot(), 'logs', 'service.log')
206
+ try {
207
+ mkdirSync(join(dataRoot(), 'logs'), { recursive: true })
208
+ const fresh = existsSync(file) && statSync(file).size < 2 * 1024 * 1024
209
+ const stream = createWriteStream(file, { flags: fresh ? 'a' : 'w' })
210
+ stream.write(`\n===== ${new Date().toISOString()} 启动(端口 ${port})=====\n`)
211
+ log(`日志文件:${file}`)
212
+ return stream
213
+ } catch (e) {
214
+ log(`日志文件不可写,输出将丢弃:${e?.message ?? e}`)
215
+ return null
216
+ }
217
+ }
218
+
219
+ const logStream = logToFile ? openLogStream() : null
190
220
  const child = spawn(process.execPath, [...server.args, server.entry], {
191
221
  cwd: pkgRoot,
192
- stdio: 'inherit',
193
- env: { ...process.env, DESKPET_PORT: String(port) }
222
+ stdio: logStream ? ['ignore', 'pipe', 'pipe'] : 'inherit',
223
+ env: { ...process.env, DESKPET_PORT: String(port) },
224
+ /**
225
+ * windowsHide: true —— 关键的一行。
226
+ *
227
+ * Node 的这个选项默认是 **false**。它只在「父进程没有控制台、且没有继承 stdio」
228
+ * 时才起作用:那种情况下 Windows 会给子进程**新分配一个控制台窗口**,
229
+ * 也就是开机时闪一下的黑窗体。自启链路里 wscript 本身是没有控制台的图形程序,
230
+ * 一旦 stdio 走管道(--log 就是这种情况),子进程就属于这一档。
231
+ * 设为 true 会让 Node 带上 CREATE_NO_WINDOW,任何情况下都不会新建窗口。
232
+ */
233
+ windowsHide: true
194
234
  })
195
235
 
236
+ if (logStream) {
237
+ child.stdout.pipe(logStream)
238
+ child.stderr.pipe(logStream)
239
+ }
240
+
196
241
  process.on('SIGINT', () => child.kill('SIGINT'))
197
242
  process.on('SIGTERM', () => child.kill('SIGTERM'))
198
243
  child.on('exit', (code) => process.exit(code ?? 0))
@@ -2,21 +2,24 @@
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
  */
@@ -45,28 +48,35 @@ function winStartupDir() {
45
48
  const appData = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming');
46
49
  return join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
47
50
  }
48
- function winCmdPath() {
49
- return join(winStartupDir(), `${APP_ID}.cmd`);
51
+ function winVbsPath() {
52
+ return join(winStartupDir(), `${APP_ID}.vbs`);
50
53
  }
51
- function winLegacyLnkPath() {
52
- return join(winStartupDir(), `${APP_ID}.lnk`);
54
+ /** 历史版本写过的自启文件:Electron 时代的快捷方式、上一版的批处理 */
55
+ function winLegacyPaths() {
56
+ return [join(winStartupDir(), `${APP_ID}.lnk`), join(winStartupDir(), `${APP_ID}.cmd`)];
53
57
  }
54
58
  /**
55
- * 生成自启批处理。
59
+ * 生成自启脚本(VBScript)。
56
60
  *
57
- * 注意两点:
58
- * · 换行必须是 CRLF,注释用英文 —— cmd 按系统 ANSI 代码页解析批处理,
59
- * 写中文注释在中文 Windows 上容易乱码(虽然注释不影响执行,但很难看)
60
- * · `start /min` 让服务窗口最小化,不打扰用户,同时任务栏里能看到它在跑
61
+ * 为什么不是 .cmd:批处理启动的任何控制台程序都会**弹出一个窗口**,
62
+ * `start /min` 只能把它最小化 —— 任务栏里始终挂着一个 node.exe,很碍眼。
63
+ * 改用 WScript.Shell.Run 的第二参数(窗口样式)传 0,窗口**完全隐藏**:
64
+ * 0 = 隐藏窗口,False = 不等待其结束(否则 wscript 会一直挂着)
65
+ *
66
+ * 编码用 UTF-16LE 带 BOM:wscript 默认按系统 ANSI 代码页读 .vbs,
67
+ * 若安装路径含中文(例如用户名是中文)会乱码成乱路径;带 BOM 则按 Unicode 解析。
68
+ *
69
+ * 隐藏窗口后看不到服务日志,所以启动命令带 `--log`:输出转存到
70
+ * <数据目录>/logs/service.log(见 bin/deskpet.mjs)。
61
71
  */
62
- function winCmdContent() {
72
+ function winVbsContent() {
63
73
  const { node, entry } = launch();
64
74
  return [
65
- '@echo off',
66
- 'rem DeskPet autostart - generated by the DeskPet dashboard.',
67
- 'rem Delete this file to disable autostart.',
68
- `cd /d "${appRoot()}"`,
69
- `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`,
70
80
  ''
71
81
  ].join('\r\n');
72
82
  }
@@ -120,21 +130,24 @@ export async function setAutoStart(on, opts = {}) {
120
130
  if (process.platform === 'win32') {
121
131
  const dir = winStartupDir();
122
132
  mkdirSync(dir, { recursive: true });
123
- // 清掉 Electron 时代遗留的失效快捷方式(它指向 pet\dist\DeskPet.exe)
124
- const legacy = winLegacyLnkPath();
125
- if (existsSync(legacy)) {
133
+ // 清掉历史遗留:Electron 时代的 .lnk(指向已不存在的 pet\dist\DeskPet.exe)
134
+ // 与上一版的 .cmd(会弹窗口)。留着只会重复启动或误导排查。
135
+ for (const legacy of winLegacyPaths()) {
136
+ if (!existsSync(legacy))
137
+ continue;
126
138
  try {
127
139
  unlinkSync(legacy);
128
- console.log(`[autostart] 已清理旧版自启快捷方式: ${legacy}`);
140
+ console.log(`[autostart] 已清理旧版自启文件: ${legacy}`);
129
141
  }
130
142
  catch (err) {
131
- console.warn(`[autostart] 旧快捷方式清理失败(可手动删除): ${String(err)}`);
143
+ console.warn(`[autostart] 旧自启文件清理失败(可手动删除): ${String(err)}`);
132
144
  }
133
145
  }
134
- const file = winCmdPath();
146
+ const file = winVbsPath();
135
147
  if (on) {
136
- writeFileSync(file, winCmdContent(), 'utf-8');
137
- console.log(`[autostart] 开机自启已开启: ${file}`);
148
+ // utf16le + BOM:见 winVbsContent() 的说明(路径含中文时不会被 ANSI 解析搞坏)
149
+ writeFileSync(file, Buffer.from(`\ufeff${winVbsContent()}`, 'utf16le'));
150
+ console.log(`[autostart] 开机自启已开启(静默,无窗口): ${file}`);
138
151
  }
139
152
  else if (removeWhenOff && existsSync(file)) {
140
153
  unlinkSync(file);
@@ -179,8 +192,10 @@ export async function setAutoStart(on, opts = {}) {
179
192
  /** 查询当前是否已设置自启 */
180
193
  export function autoStartEnabled() {
181
194
  try {
182
- if (process.platform === 'win32')
183
- return existsSync(winCmdPath());
195
+ if (process.platform === 'win32') {
196
+ // .cmd 是上一版留下的形式,也算「已开启」,直到下次写入时被替换
197
+ return winLegacyPaths().some((p) => p.endsWith('.cmd') && existsSync(p)) || existsSync(winVbsPath());
198
+ }
184
199
  if (process.platform === 'darwin')
185
200
  return existsSync(macPlistPath());
186
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.3",
4
+ "version": "2.0.5",
5
5
  "description": "DeskPet 2.0 — 本机任务自动化服务 + Web 管理台 (Node + Express + Vue 3 + TypeScript)",
6
6
  "type": "module",
7
7
  "author": "whp",
@@ -2,21 +2,24 @@
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
  */
@@ -51,30 +54,37 @@ function winStartupDir(): string {
51
54
  return join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
52
55
  }
53
56
 
54
- function winCmdPath(): string {
55
- return join(winStartupDir(), `${APP_ID}.cmd`)
57
+ function winVbsPath(): string {
58
+ return join(winStartupDir(), `${APP_ID}.vbs`)
56
59
  }
57
60
 
58
- function winLegacyLnkPath(): string {
59
- return join(winStartupDir(), `${APP_ID}.lnk`)
61
+ /** 历史版本写过的自启文件:Electron 时代的快捷方式、上一版的批处理 */
62
+ function winLegacyPaths(): string[] {
63
+ return [join(winStartupDir(), `${APP_ID}.lnk`), join(winStartupDir(), `${APP_ID}.cmd`)]
60
64
  }
61
65
 
62
66
  /**
63
- * 生成自启批处理。
67
+ * 生成自启脚本(VBScript)。
64
68
  *
65
- * 注意两点:
66
- * · 换行必须是 CRLF,注释用英文 —— cmd 按系统 ANSI 代码页解析批处理,
67
- * 写中文注释在中文 Windows 上容易乱码(虽然注释不影响执行,但很难看)
68
- * · `start /min` 让服务窗口最小化,不打扰用户,同时任务栏里能看到它在跑
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 解析。
76
+ *
77
+ * 隐藏窗口后看不到服务日志,所以启动命令带 `--log`:输出转存到
78
+ * <数据目录>/logs/service.log(见 bin/deskpet.mjs)。
69
79
  */
70
- function winCmdContent(): string {
80
+ function winVbsContent(): string {
71
81
  const { node, entry } = launch()
72
82
  return [
73
- '@echo off',
74
- 'rem DeskPet autostart - generated by the DeskPet dashboard.',
75
- 'rem Delete this file to disable autostart.',
76
- `cd /d "${appRoot()}"`,
77
- `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`,
78
88
  ''
79
89
  ].join('\r\n')
80
90
  }
@@ -151,21 +161,23 @@ export async function setAutoStart(on: boolean, opts: AutoStartOptions = {}): Pr
151
161
  const dir = winStartupDir()
152
162
  mkdirSync(dir, { recursive: true })
153
163
 
154
- // 清掉 Electron 时代遗留的失效快捷方式(它指向 pet\dist\DeskPet.exe)
155
- const legacy = winLegacyLnkPath()
156
- if (existsSync(legacy)) {
164
+ // 清掉历史遗留:Electron 时代的 .lnk(指向已不存在的 pet\dist\DeskPet.exe)
165
+ // 与上一版的 .cmd(会弹窗口)。留着只会重复启动或误导排查。
166
+ for (const legacy of winLegacyPaths()) {
167
+ if (!existsSync(legacy)) continue
157
168
  try {
158
169
  unlinkSync(legacy)
159
- console.log(`[autostart] 已清理旧版自启快捷方式: ${legacy}`)
170
+ console.log(`[autostart] 已清理旧版自启文件: ${legacy}`)
160
171
  } catch (err) {
161
- console.warn(`[autostart] 旧快捷方式清理失败(可手动删除): ${String(err)}`)
172
+ console.warn(`[autostart] 旧自启文件清理失败(可手动删除): ${String(err)}`)
162
173
  }
163
174
  }
164
175
 
165
- const file = winCmdPath()
176
+ const file = winVbsPath()
166
177
  if (on) {
167
- writeFileSync(file, winCmdContent(), 'utf-8')
168
- console.log(`[autostart] 开机自启已开启: ${file}`)
178
+ // utf16le + BOM:见 winVbsContent() 的说明(路径含中文时不会被 ANSI 解析搞坏)
179
+ writeFileSync(file, Buffer.from(`\ufeff${winVbsContent()}`, 'utf16le'))
180
+ console.log(`[autostart] 开机自启已开启(静默,无窗口): ${file}`)
169
181
  } else if (removeWhenOff && existsSync(file)) {
170
182
  unlinkSync(file)
171
183
  console.log('[autostart] 开机自启已关闭')
@@ -210,7 +222,10 @@ export async function setAutoStart(on: boolean, opts: AutoStartOptions = {}): Pr
210
222
  /** 查询当前是否已设置自启 */
211
223
  export function autoStartEnabled(): boolean {
212
224
  try {
213
- 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
+ }
214
229
  if (process.platform === 'darwin') return existsSync(macPlistPath())
215
230
  if (process.platform === 'linux') return existsSync(linuxDesktopPath())
216
231
  } catch {