pi-web-ui 0.52.0 → 0.54.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/README.md CHANGED
@@ -154,7 +154,7 @@ npm i -g --allow-scripts=node-pty,@google/genai,protobufjs pi-web-ui@latest
154
154
 
155
155
  ```bash
156
156
  pi-web-ui # foreground, http://localhost:8787
157
- PORT=9000 PI_WEB_CWD=/path/to/project pi-web-ui # custom port / workspace
157
+ PI_WEB_PORT=9000 PI_WEB_CWD=/path/to/project pi-web-ui # custom port / workspace
158
158
  ```
159
159
 
160
160
  **Stop**
package/bin/pi-web-ui.mjs CHANGED
@@ -17,14 +17,14 @@
17
17
  * - macOS → launchd 用户代理,label 默认 com.xingshuyin.pi-web-ui
18
18
  * (--name 自定义时 com.<name>.server),无需 sudo
19
19
  * - Linux → systemd 单元 <name>.service(/etc/systemd/system/,自动 sudo)
20
- * - Windows → 计划任务(Task Scheduler / schtasks,登录后自启,无需管理员),
21
- * 隐藏窗口启动(无黑窗);PowerShell 启动脚本与任务 XML 生成在
20
+ * - Windows → 登录自启 Run 键(HKCU,无需管理员)+ wscript 隐藏启动(无黑窗);
21
+ * PowerShell 启动脚本 / VBS 启动器 / PID 文件生成在
22
22
  * %APPDATA%\pi-web-ui\
23
23
  *
24
- * 环境变量(前台与系统服务均适用):PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
24
+ * 环境变量(前台与系统服务均适用):PI_WEB_PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
25
25
  * PI_CODING_AGENT_DIR。
26
26
  */
27
- import { spawnSync } from "node:child_process";
27
+ import { spawn, spawnSync } from "node:child_process";
28
28
  import { createConnection } from "node:net";
29
29
  import { get as httpGet } from "node:http";
30
30
  import {
@@ -77,15 +77,15 @@ const HELP = `pi-web-ui v${pkg.version} — web chat for the pi coding agent
77
77
  pi-web-ui --version / --help
78
78
 
79
79
  server 选项:
80
- --port <n> 端口(默认 8787,或 $PORT
81
- --cwd <dir> 工作目录(默认 $PI_WEB_CWD 或当前目录)
80
+ --port <n> 端口(默认 8787,或 $PI_WEB_PORT
81
+ --cwd <dir> 工作目录(默认 $PI_WEB_CWD 或用户主目录;前台启动默认当前目录)
82
82
  --data-dir <dir> 会话数据目录(默认 <cwd>/.pi-web)
83
83
  --name <name> 服务名(默认 pi-web-ui;macOS 的 launchd label
84
84
  为 com.xingshuyin.pi-web-ui,自定义名时为 com.<name>.server)
85
85
  --print 只打印将生成的配置文件,不实际安装
86
86
 
87
- 平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 计划任务(schtasks)
88
- Windows 任务登录后自启、无需管理员、隐藏窗口运行;stop 停止,uninstall 移除)
87
+ 平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 登录自启 Run 键
88
+ HKCU 写入无需管理员;wscript 隐藏启动无黑窗;stop 停止,uninstall 移除)
89
89
  快捷方式: Windows → 桌面 .lnk · macOS → 桌面 .command 启动器 · Linux → 桌面 .desktop 图标
90
90
 
91
91
  界面插件(安装到 <data-dir>/plugins/,服务运行中刷新浏览器即生效):
@@ -101,7 +101,7 @@ server 选项:
101
101
  --force 目标已存在时覆盖
102
102
 
103
103
  环境变量(前台与系统服务均适用):
104
- PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
104
+ PI_WEB_PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
105
105
  `;
106
106
 
107
107
  /** Minimum Node required by the pi SDK (its dist uses `import … with { type: "json" }`). */
@@ -258,12 +258,10 @@ function openBrowserWhenUp(url) {
258
258
  }
259
259
 
260
260
  async function startForeground(opts) {
261
- if (opts.port) process.env.PORT = opts.port;
261
+ if (opts.port) process.env.PI_WEB_PORT = opts.port;
262
262
  if (opts.cwd) process.env.PI_WEB_CWD = resolve(opts.cwd);
263
263
  if (opts.dataDir) process.env.PI_WEB_DATA_DIR = resolve(opts.dataDir);
264
- const url = `http://localhost:${String(
265
- opts.port ?? process.env.PORT ?? "8787",
266
- )}`;
264
+ const url = `http://localhost:${effectivePort(opts)}`;
267
265
  await import(pathToFileURL(SERVER_ENTRY).href);
268
266
  if (!opts.noBrowser) openBrowserWhenUp(url);
269
267
  }
@@ -307,7 +305,7 @@ function systemdUnitPath(name) {
307
305
  return `/etc/systemd/system/${name}.service`;
308
306
  }
309
307
 
310
- /** Windows: per-user config dir (%APPDATA%\pi-web-ui) holding the .cmd wrapper + task XML. */
308
+ /** Windows: per-user config dir (%APPDATA%\pi-web-ui) holding the ps1/vbs launchers + pid file. */
311
309
  function winServiceDir() {
312
310
  return join(
313
311
  process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
@@ -323,12 +321,17 @@ function winPs1Path(name) {
323
321
  return join(winServiceDir(), `${name}.ps1`);
324
322
  }
325
323
 
324
+ function winVbsPath(name) {
325
+ return join(winServiceDir(), `${name}.vbs`);
326
+ }
327
+
326
328
  function winTaskXmlPath(name) {
327
329
  return join(winServiceDir(), `${name}.xml`);
328
330
  }
329
331
 
330
- function winLogPath() {
331
- return join(homedir(), "pi-web-ui.log");
332
+ /** Windows log file (per service name — multiple services must not share one log). */
333
+ function winLogPath(name) {
334
+ return join(homedir(), name === "pi-web-ui" ? "pi-web-ui.log" : `pi-web-ui-${name}.log`);
332
335
  }
333
336
 
334
337
  /** True when a scheduled task with this name exists (schtasks exits 0). */
@@ -339,6 +342,38 @@ function winTaskExists(name) {
339
342
  );
340
343
  }
341
344
 
345
+ /** Windows: per-user autostart registry key (HKCU — writable without admin). */
346
+ function winRunKeyName() {
347
+ return "Software\\Microsoft\\Windows\\CurrentVersion\\Run";
348
+ }
349
+
350
+ /** True when the per-user autostart Run key value exists. */
351
+ function winRunKeyInstalled(name) {
352
+ return (
353
+ spawnSync("reg", ["query", `HKCU\\${winRunKeyName()}`, "/v", name], {
354
+ stdio: "ignore",
355
+ }).status === 0
356
+ );
357
+ }
358
+
359
+ /** Set the per-user autostart Run key value (no admin needed for HKCU). */
360
+ function winRunKeySet(name, value) {
361
+ run(
362
+ "reg",
363
+ ["add", `HKCU\\${winRunKeyName()}`, "/v", name, "/t", "REG_SZ", "/d", value, "/f"],
364
+ { silent: true },
365
+ );
366
+ }
367
+
368
+ /** Remove the per-user autostart Run key value (missing key is a no-op). */
369
+ function winRunKeyDelete(name) {
370
+ run(
371
+ "reg",
372
+ ["delete", `HKCU\\${winRunKeyName()}`, "/v", name, "/f"],
373
+ { ignoreError: true, silent: true },
374
+ );
375
+ }
376
+
342
377
  // ---------------------------------------------------------------------------
343
378
  // 桌面快捷方式(server shortcut)
344
379
  // ---------------------------------------------------------------------------
@@ -434,10 +469,10 @@ function shQuote(s) {
434
469
 
435
470
  /**
436
471
  * Windows shortcut launcher. Double-click the .lnk → this script runs hidden:
437
- * server already up → just open the browser; scheduled task installed → start
438
- * it (manageable via `server stop`); otherwise run the server in the foreground
439
- * of this hidden window and record its PID so `server stop` / `server
440
- * uninstall` can terminate it too.
472
+ * server already up → just open the browser; autostart service installed
473
+ * (HKCU Run key + wscript/VBS launcher) start it (manageable via `server
474
+ * stop`); otherwise run the server in the foreground of this hidden window and
475
+ * record its PID so `server stop` / `server uninstall` can terminate it too.
441
476
  */
442
477
  function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
443
478
  const sets = Object.entries(env)
@@ -447,13 +482,13 @@ function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
447
482
  return [
448
483
  "# Generated by: pi-web-ui server shortcut (rerun to change)",
449
484
  "# Runs hidden from the desktop shortcut: if the server is already up it",
450
- "# opens the browser; a scheduled task (if installed) is used; otherwise",
451
- "# the server runs in the foreground of this hidden window and its PID is",
452
- "# recorded so `server stop` / `server uninstall` can terminate it.",
485
+ "# opens the browser; the autostart service (if installed) is started;",
486
+ "# otherwise the server runs in the foreground of this hidden window and",
487
+ "# its PID is recorded so `server stop` / `server uninstall` can stop it.",
453
488
  "$ErrorActionPreference = 'Continue'",
454
489
  `$url = ${psQuote(url)}`,
455
490
  `$health = ${psQuote(url + "/api/health")}`,
456
- `$taskName = ${psQuote(taskName)}`,
491
+ `$svcName = ${psQuote(taskName)}`,
457
492
  `$pidFile = ${psQuote(pidPath)}`,
458
493
  `$log = ${psQuote(logPath)}`,
459
494
  "",
@@ -465,9 +500,10 @@ function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
465
500
  "# 已在运行 → 直接打开浏览器",
466
501
  "if (Test-Up) { Open-Browser; exit 0 }",
467
502
  "",
468
- "# 已安装计划任务 走服务启动(server stop 可管理)",
469
- "if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {",
470
- " schtasks /Run /TN $taskName | Out-Null",
503
+ "# 已安装自启服务(HKCU Run 键 + wscript 隐藏启动)→ 走服务启动(server stop 可管理)",
504
+ "$vbs = Join-Path $env:APPDATA ('pi-web-ui\\' + $svcName + '.vbs')",
505
+ "if (Test-Path $vbs) {",
506
+ " wscript.exe $vbs",
471
507
  " for ($i = 0; $i -lt 120; $i++) {",
472
508
  " Start-Sleep -Milliseconds 250",
473
509
  " if (Test-Up) { Open-Browser; exit 0 }",
@@ -496,13 +532,15 @@ function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
496
532
 
497
533
 
498
534
  /**
499
- * Build the tiny VBS launcher the desktop .lnk invokes. Its purpose is purely to
500
- * start the .ps1 *without* creating any console host: wscript.exe has no console,
501
- * and WScript.Shell.Run(…, 0, False) launches the child hidden and returns at once
502
- * (0 = hidden window, False = don't wait). Result: double-clicking the icon never
503
- * flashes a black box and no leftover console appears in the taskbar.
535
+ * Build the tiny VBS launcher that starts a .ps1 *without* creating any console
536
+ * host: wscript.exe has no console, and WScript.Shell.Run(…, 0, False) launches
537
+ * the child hidden and returns at once (0 = hidden window, False = don't wait).
538
+ * Used by both the desktop .lnk and the logon autostart service. Note:
539
+ * `powershell -WindowStyle Hidden` alone is unreliable when Windows itself
540
+ * spawns the process (Task Scheduler) — the console window can still show;
541
+ * this launcher never creates one at all.
504
542
  */
505
- function buildWinShortcutVbs(ps1Path) {
543
+ function buildWinHiddenVbs(ps1Path) {
506
544
  const cmd = `${winPowershell()} -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${ps1Path}"`;
507
545
  // VBScript 字符串没有 \" 转义(也没有 \uXXXX),内嵌引号必须写成 "";
508
546
  // 不能用 JSON.stringify —— 它输出 \" 会在 VBScript 里提前结束字符串(语句未结束 800A0401),
@@ -534,7 +572,7 @@ function installWinShortcut(opts) {
534
572
  cwd,
535
573
  name,
536
574
  url,
537
- winLogPath(),
575
+ winLogPath(name),
538
576
  winPidFilePath(name),
539
577
  );
540
578
  if (opts.print) {
@@ -545,7 +583,7 @@ function installWinShortcut(opts) {
545
583
  writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8"); // PS 5.1 需要 BOM
546
584
  // wscript host + VBS launcher: no console window / taskbar black box on double-click.
547
585
  const vbsPath = winShortcutVbsPath(name);
548
- writeFileSync(vbsPath, "\uFEFF" + buildWinShortcutVbs(ps1Path), "utf16le"); // wscript 只认 UTF-16/ANSI,UTF-8 BOM 会报“无效字符”,中文路径用 utf16le + BOM
586
+ writeFileSync(vbsPath, "\uFEFF" + buildWinHiddenVbs(ps1Path), "utf16le"); // wscript 只认 UTF-16/ANSI,UTF-8 BOM 会报“无效字符”,中文路径用 utf16le + BOM
549
587
  // 把品牌图标准备好:复制到用户目录(.lnk 图标指向稳定路径)
550
588
  if (existsSync(APP_ICO_SOURCE)) {
551
589
  copyFileSync(APP_ICO_SOURCE, winIcoPath());
@@ -768,85 +806,38 @@ function psQuote(s) {
768
806
  }
769
807
 
770
808
  /**
771
- * Build the PowerShell launcher the scheduled task runs. Task Scheduler
772
- * launches it with -WindowStyle Hidden, so the console window is created
773
- * hidden (SW_HIDE) no black cmd window stays open while the server runs,
774
- * and there is nothing to accidentally close/kill. The script sets the env,
775
- * cd's to the workspace, then runs node in the foreground with output
776
- * appended to the log, so the task instance IS the powershell process and
777
- * `schtasks /End` still stops the whole tree.
809
+ * Build the PowerShell launcher the autostart service runs. Windows never sees
810
+ * a console from it: the HKCU Run key launches wscript.exe VBS → powershell
811
+ * (hidden), so no black box can appear. The script sets the env, cd's to the
812
+ * workspace, records its PID to <name>.pid (for `server stop`), then runs node
813
+ * inside a watchdog loop if node exits, it restarts after 10s (same
814
+ * philosophy as launchd KeepAlive / systemd Restart=always). `server stop`
815
+ * force-kills the recorded PID tree, which takes the loop down with it.
778
816
  */
779
- function buildWinStartPs1(env, cwd, logPath) {
817
+ function buildWinStartPs1(env, cwd, logPath, pidPath) {
780
818
  const sets = Object.entries(env)
781
819
  .map(([k, v]) => `$env:${k} = ${psQuote(v)}`)
782
820
  .join("\r\n");
783
821
  return [
784
822
  "# Generated by: pi-web-ui server install (rerun to change)",
785
- "# Starts the server with a hidden console window (no black cmd box).",
823
+ "# Runs the server with no console window (wscript+VBS launcher) and",
824
+ "# restarts it if it crashes (watchdog, like launchd/systemd).",
786
825
  sets,
787
826
  `Set-Location ${psQuote(cwd)}`,
788
- `& ${psQuote(NODE)} ${psQuote(SERVER_ENTRY)} *>> ${psQuote(logPath)}`,
827
+ `$PID | Out-File -Encoding ascii ${psQuote(pidPath)}`,
828
+ "try {",
829
+ " while ($true) {",
830
+ ` & ${psQuote(realNode())} ${psQuote(SERVER_ENTRY)} *>> ${psQuote(logPath)}`,
831
+ " Start-Sleep 10",
832
+ " }",
833
+ "} finally {",
834
+ ` Remove-Item ${psQuote(pidPath)} -ErrorAction SilentlyContinue`,
835
+ "}",
789
836
  "",
790
837
  ].join("\r\n");
791
838
  }
792
839
 
793
- /**
794
- * Build the Task Scheduler XML for a user task: LogonTrigger (starts at logon,
795
- * like a launchd agent — no admin needed), InteractiveToken, auto-restart on
796
- * failure. The task runs the PowerShell launcher via
797
- * powershell.exe -WindowStyle Hidden, so no console window ever appears.
798
- */
799
- function buildWinTaskXml(ps1Path, cwd) {
800
- const powershell = winPowershell();
801
- return `<?xml version="1.0" encoding="UTF-16"?>
802
- <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
803
- <RegistrationInfo>
804
- <Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
805
- </RegistrationInfo>
806
- <Triggers>
807
- <LogonTrigger>
808
- <Enabled>true</Enabled>
809
- </LogonTrigger>
810
- </Triggers>
811
- <Principals>
812
- <Principal id="Author">
813
- <LogonType>InteractiveToken</LogonType>
814
- <RunLevel>LeastPrivilege</RunLevel>
815
- </Principal>
816
- </Principals>
817
- <Settings>
818
- <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
819
- <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
820
- <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
821
- <AllowHardTerminate>true</AllowHardTerminate>
822
- <StartWhenAvailable>false</StartWhenAvailable>
823
- <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
824
- <IdleSettings>
825
- <StopOnIdleEnd>false</StopOnIdleEnd>
826
- <RestartOnIdle>false</RestartOnIdle>
827
- </IdleSettings>
828
- <AllowStartOnDemand>true</AllowStartOnDemand>
829
- <Enabled>true</Enabled>
830
- <Hidden>false</Hidden>
831
- <RunOnlyIfIdle>false</RunOnlyIfIdle>
832
- <WakeToRun>false</WakeToRun>
833
- <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
834
- <Priority>7</Priority>
835
- <RestartOnFailure>
836
- <Interval>PT1M</Interval>
837
- <Count>3</Count>
838
- </RestartOnFailure>
839
- </Settings>
840
- <Actions Context="Author">
841
- <Exec>
842
- <Command>${esc(powershell)}</Command>
843
- <Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${esc(ps1Path)}"</Arguments>
844
- <WorkingDirectory>${esc(cwd)}</WorkingDirectory>
845
- </Exec>
846
- </Actions>
847
- </Task>
848
- `;
849
- }
840
+
850
841
 
851
842
  function esc(s) {
852
843
  return s
@@ -856,6 +847,35 @@ function esc(s) {
856
847
  .replace(/"/g, "&quot;");
857
848
  }
858
849
 
850
+ /** Start the autostart service now: wscript runs the VBS hidden and exits at once. */
851
+ function launchWinService(vbsPath) {
852
+ const child = spawn(winWscript(), [vbsPath], {
853
+ detached: true,
854
+ stdio: "ignore",
855
+ windowsHide: true,
856
+ });
857
+ child.unref();
858
+ }
859
+
860
+ /** Kill a running Windows instance via its PID file (whole tree). */
861
+ function stopWinInstance(name) {
862
+ const pid = winReadPid(name);
863
+ if (pid) {
864
+ if (pidAlive(pid)) {
865
+ run("taskkill", ["/PID", String(pid), "/T", "/F"], {
866
+ ignoreError: true,
867
+ silent: true,
868
+ });
869
+ // taskkill /F 异步生效:等到进程树真正退出(restart 需要避免端口竞争)
870
+ const deadline = Date.now() + 5000;
871
+ while (Date.now() < deadline && pidAlive(pid)) {
872
+ spawnSync("ping", ["-n", "2", "127.0.0.1"], { stdio: "ignore" });
873
+ }
874
+ }
875
+ rmSync(winPidFilePath(name), { force: true });
876
+ }
877
+ }
878
+
859
879
  /** Build the launchd plist XML. */
860
880
  function buildPlist(label, cwd, env) {
861
881
  const entries = Object.entries(env)
@@ -936,14 +956,21 @@ function ensureRootForSystemctl() {
936
956
  process.exit(res.status ?? 1);
937
957
  }
938
958
 
959
+ /** Resolve the effective HTTP port: --port > $PI_WEB_PORT > legacy $PORT > 8787. */
960
+ function effectivePort(opts) {
961
+ return String(opts.port ?? process.env.PI_WEB_PORT ?? process.env.PORT ?? "8787");
962
+ }
963
+
939
964
  /** Shared option normalization for install. */
940
965
  function serviceOptions(opts) {
941
966
  const name = opts.name ?? "pi-web-ui";
942
- const port = String(opts.port ?? process.env.PORT ?? "8787");
967
+ const port = effectivePort(opts);
943
968
  if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
944
969
  fail(`无效端口: ${port}`);
945
970
  }
946
- const cwd = resolve(opts.cwd ?? process.env.PI_WEB_CWD ?? process.cwd());
971
+ // 服务默认以用户主目录为工作目录:安装命令的当前目录不可靠(例如 Windows 提权提示符
972
+ // 默认在 C:\WINDOWS\system32),主目录跨平台可预期;前台启动仍默认当前目录。
973
+ const cwd = resolve(opts.cwd ?? process.env.PI_WEB_CWD ?? homedir());
947
974
  if (!existsSync(cwd)) fail(`工作目录不存在: ${cwd}`);
948
975
  let dataDir;
949
976
  if (opts.dataDir) {
@@ -956,7 +983,7 @@ function serviceOptions(opts) {
956
983
 
957
984
  function serviceEnv(port, cwd, dataDir) {
958
985
  const env = {
959
- PORT: port,
986
+ PI_WEB_PORT: port,
960
987
  PI_WEB_CWD: cwd,
961
988
  };
962
989
  // Interactive Windows tasks inherit the user's PATH; only systemd/launchd
@@ -1051,59 +1078,65 @@ function installWindows(opts) {
1051
1078
  const { name, port, cwd, dataDir } = serviceOptions(opts);
1052
1079
  const env = serviceEnv(port, cwd, dataDir);
1053
1080
  const ps1Path = winPs1Path(name);
1054
- const xmlPath = winTaskXmlPath(name);
1055
- const ps1 = buildWinStartPs1(env, cwd, winLogPath());
1056
- const xml = buildWinTaskXml(ps1Path, cwd);
1081
+ const vbsPath = winVbsPath(name);
1082
+ const pidPath = winPidFilePath(name);
1083
+ const ps1 = buildWinStartPs1(env, cwd, winLogPath(name), pidPath);
1084
+ const vbs = buildWinHiddenVbs(ps1Path);
1085
+ // HKCU Run 键值:wscript.exe 以隐藏方式启动 VBS(无控制台,登录后自启)
1086
+ const runValue = `"${winWscript()}" "${vbsPath}"`;
1057
1087
  if (opts.print) {
1058
1088
  console.log(`# ${ps1Path}\n${ps1}`);
1059
- console.log(`# ${xmlPath}\n${xml}`);
1089
+ console.log(`# ${vbsPath}\n${vbs}`);
1090
+ console.log(`# 登录自启(HKCU Run 键,无需管理员)`);
1091
+ console.log(` reg add "HKCU\\${winRunKeyName()}" /v ${name} /t REG_SZ /d "${runValue}" /f`);
1060
1092
  return;
1061
1093
  }
1062
1094
  mkdirSync(dirname(ps1Path), { recursive: true });
1063
1095
  // UTF-8 with BOM: Windows PowerShell 5.1 misreads BOM-less UTF-8 as ANSI.
1064
1096
  writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8");
1065
- // Remove the old-style .cmd wrapper from previous installs.
1097
+ // wscript host + VBS launcher: no console window / taskbar black box ever.
1098
+ writeFileSync(vbsPath, "\uFEFF" + vbs, "utf16le"); // wscript 只认 UTF-16/ANSI,UTF-8 BOM 会报“无效字符”
1099
+ // 迁移:移除旧版 .cmd 包装与历史计划任务(普通用户下 schtasks 无法创建,改为 Run 键)。
1066
1100
  if (existsSync(winCmdPath(name))) rmSync(winCmdPath(name));
1067
- // schtasks /Create /XML requires a UTF-16 file (with BOM).
1068
- writeFileSync(xmlPath, "\uFEFF" + xml, "utf16le");
1069
1101
  if (winTaskExists(name)) {
1102
+ run("schtasks", ["/End", "/TN", name], {
1103
+ ignoreError: true,
1104
+ silent: true,
1105
+ });
1106
+ // /End 异步生效:稍候再删任务,避免新实例与旧实例端口竞争
1107
+ spawnSync("ping", ["-n", "2", "127.0.0.1"], { stdio: "ignore" });
1070
1108
  run("schtasks", ["/Delete", "/TN", name, "/F"], {
1071
1109
  ignoreError: true,
1072
1110
  silent: true,
1073
1111
  });
1074
1112
  }
1075
- run("schtasks", ["/Create", "/TN", name, "/XML", xmlPath, "/F"]);
1076
- run("schtasks", ["/Run", "/TN", name], { ignoreError: true });
1077
- console.log(`✅ 已安装并启动计划任务 ${name}(隐藏窗口运行,无黑窗)`);
1113
+ winRunKeySet(name, runValue);
1114
+ launchWinService(vbsPath);
1115
+ console.log(`✅ 已安装并启动 ${name}(登录自启 · HKCU Run 键 · 无需管理员)`);
1116
+ console.log(` 窗口 : wscript 隐藏启动,无黑窗`);
1078
1117
  console.log(` 端口 : ${port}`);
1079
1118
  console.log(` 目录 : ${cwd}`);
1080
1119
  console.log(` 访问 : http://localhost:${port}`);
1081
- console.log(` 日志 : ${winLogPath()}`);
1082
- console.log(
1083
- ` 说明 : 登录后自启(与 launchd 用户代理一致);stop 停止,uninstall 移除`,
1084
- );
1120
+ console.log(` 日志 : ${winLogPath(name)}`);
1121
+ console.log(` 说明 : 崩溃后 10 秒自动重启(看门狗);stop 停止,uninstall 移除`);
1085
1122
  console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
1086
1123
  console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
1087
1124
  }
1088
1125
 
1089
1126
  function uninstallWindows(opts) {
1090
1127
  const name = opts.name ?? "pi-web-ui";
1128
+ winRunKeyDelete(name);
1129
+ // 历史计划任务(旧版本 install 可能注册过)
1091
1130
  if (winTaskExists(name)) {
1092
1131
  run("schtasks", ["/Delete", "/TN", name, "/F"], { ignoreError: true });
1093
1132
  }
1094
- for (const f of [winCmdPath(name), winPs1Path(name), winTaskXmlPath(name)]) {
1133
+ // 运行中的实例(服务或快捷方式启动,均记录 PID 文件)
1134
+ stopWinInstance(name);
1135
+ for (const f of [winCmdPath(name), winPs1Path(name), winVbsPath(name), winTaskXmlPath(name)]) {
1095
1136
  if (existsSync(f)) rmSync(f);
1096
1137
  }
1097
- // 快捷方式启动的隐藏实例
1098
- const pid = winReadPid(name);
1099
- if (pid && pidAlive(pid)) {
1100
- run("taskkill", ["/PID", String(pid), "/T", "/F"], {
1101
- ignoreError: true,
1102
- silent: true,
1103
- });
1104
- }
1105
1138
  removeShortcut(name);
1106
- console.log(`🗑 已卸载 ${name}(计划任务已删除,不再自启)`);
1139
+ console.log(`🗑 已卸载 ${name}(登录自启已移除,不再开机自启)`);
1107
1140
  console.log(`🗑 已移除桌面快捷方式`);
1108
1141
  }
1109
1142
 
@@ -1123,7 +1156,7 @@ function controlPath(opts) {
1123
1156
  ? resolve(process.env.PI_WEB_DATA_DIR)
1124
1157
  : join(homedir(), ".pi-web");
1125
1158
  return isWin
1126
- ? `\\\\.\\pipe\\pi-web-ui-${String(opts.port ?? process.env.PORT ?? "8787")}`
1159
+ ? `\\\\.\\pipe\\pi-web-ui-${effectivePort(opts)}`
1127
1160
  : join(dir, "pi-web-ui.sock");
1128
1161
  }
1129
1162
 
@@ -1259,81 +1292,78 @@ function controlService(action, opts) {
1259
1292
  }
1260
1293
 
1261
1294
  if (isWin) {
1262
- const exists = winTaskExists(name);
1295
+ const installed = winRunKeyInstalled(name);
1296
+ const legacy = !installed && winTaskExists(name); // 旧版计划任务安装(未迁移)
1263
1297
 
1264
1298
  if (action === "status") {
1265
1299
  const pid = winReadPid(name);
1266
1300
  const instAlive = pid && pidAlive(pid);
1267
- if (!exists) {
1268
- console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
1269
- if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
1301
+ if (legacy) {
1302
+ console.log(`${name}: 旧版计划任务安装(未迁移)`);
1303
+ console.log(` 提示 : 重新执行 server install 可迁移到登录自启模式(删除任务,无需管理员)`);
1304
+ if (instAlive) console.log(` 实例 : 运行中 (PID ${pid})`);
1270
1305
  return;
1271
1306
  }
1272
- // Get-ScheduledTask outputs English state enums — locale-independent,
1273
- // unlike `schtasks /Query` tables on localized Windows.
1274
- const ps = spawnSync(
1275
- "powershell.exe",
1276
- [
1277
- "-NoProfile",
1278
- "-NonInteractive",
1279
- "-Command",
1280
- "$t=Get-ScheduledTask -TaskName '" +
1281
- name +
1282
- "' -ErrorAction SilentlyContinue;" +
1283
- "if(!$t){'NOT_INSTALLED';exit}" +
1284
- "$i=$t|Get-ScheduledTaskInfo;" +
1285
- "'State: '+$t.State;" +
1286
- "'LastRunTime: '+$i.LastRunTime;" +
1287
- "'LastTaskResult: '+$i.LastTaskResult",
1288
- ],
1289
- { encoding: "utf8" },
1290
- );
1291
- if (ps.status !== 0 || (ps.stdout ?? "").includes("NOT_INSTALLED")) {
1307
+ if (!installed) {
1292
1308
  console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
1309
+ if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
1293
1310
  return;
1294
1311
  }
1295
- console.log(`${name}: 计划任务\n${(ps.stdout ?? "").trim()}`);
1296
- if (pid) {
1297
- if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
1298
- else console.log(` 快捷方式实例 : 已退出 (PID ${pid})`);
1312
+ console.log(`${name}: 已安装(登录自启 · HKCU Run 键 · wscript 隐藏启动,无黑窗)`);
1313
+ if (instAlive) {
1314
+ console.log(` 运行状态 : 运行中 (PID ${pid})`);
1315
+ console.log(` 日志 : ${winLogPath(name)}`);
1316
+ } else {
1317
+ console.log(` 运行状态 : 未运行(pi-web-ui server start 启动)`);
1299
1318
  }
1300
1319
  return;
1301
1320
  }
1302
1321
 
1303
1322
  if (action === "start") {
1304
- if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
1305
- run("schtasks", ["/Run", "/TN", name]);
1323
+ if (legacy) {
1324
+ run("schtasks", ["/Run", "/TN", name]);
1325
+ console.log(`✅ 已启动 ${name}(旧版计划任务,建议重新 server install 迁移)`);
1326
+ return;
1327
+ }
1328
+ if (!installed) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
1329
+ const pid = winReadPid(name);
1330
+ if (pid && pidAlive(pid)) {
1331
+ console.log(`✅ ${name} 已在运行 (PID ${pid})`);
1332
+ return;
1333
+ }
1334
+ launchWinService(winVbsPath(name));
1306
1335
  console.log(`✅ 已启动 ${name}`);
1307
1336
  return;
1308
1337
  }
1309
1338
 
1310
1339
  if (action === "restart") {
1311
- if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
1312
- run("schtasks", ["/End", "/TN", name], {
1313
- ignoreError: true,
1314
- silent: true,
1315
- });
1316
- run("schtasks", ["/Run", "/TN", name]);
1340
+ if (legacy) {
1341
+ run("schtasks", ["/End", "/TN", name], {
1342
+ ignoreError: true,
1343
+ silent: true,
1344
+ });
1345
+ run("schtasks", ["/Run", "/TN", name]);
1346
+ console.log(`✅ 已重启 ${name}(旧版计划任务,建议重新 server install 迁移)`);
1347
+ return;
1348
+ }
1349
+ if (!installed) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
1350
+ stopWinInstance(name);
1351
+ launchWinService(winVbsPath(name));
1317
1352
  console.log(`✅ 已重启 ${name}`);
1318
1353
  return;
1319
1354
  }
1320
1355
 
1321
1356
  if (action === "stop") {
1322
- run("schtasks", ["/End", "/TN", name], {
1323
- ignoreError: true,
1324
- silent: true,
1325
- });
1326
- const pid = winReadPid(name);
1327
- if (pid) {
1328
- if (pidAlive(pid)) {
1329
- run("taskkill", ["/PID", String(pid), "/T", "/F"], {
1330
- ignoreError: true,
1331
- silent: true,
1332
- });
1333
- console.log(`⏹ 已停止快捷方式实例 (PID ${pid})`);
1334
- }
1335
- rmSync(winPidFilePath(name), { force: true });
1357
+ if (legacy) {
1358
+ run("schtasks", ["/End", "/TN", name], {
1359
+ ignoreError: true,
1360
+ silent: true,
1361
+ });
1362
+ stopWinInstance(name);
1363
+ console.log(`⏹ 已停止 ${name}(旧版计划任务;重新 server install 可迁移到登录自启)`);
1364
+ return;
1336
1365
  }
1366
+ stopWinInstance(name);
1337
1367
  console.log(`⏹ 已停止 ${name}(自启保留;uninstall 移除)`);
1338
1368
  return;
1339
1369
  }
@@ -34,7 +34,7 @@
34
34
 
35
35
  <key>EnvironmentVariables</key>
36
36
  <dict>
37
- <key>PORT</key>
37
+ <key>PI_WEB_PORT</key>
38
38
  <string>8787</string>
39
39
  <key>PI_WEB_CWD</key>
40
40
  <string>/Users/YOUR_USER</string>
@@ -1,5 +1,5 @@
1
1
  # pi-web-ui behind nginx at a sub-path: http://<host>:83/pi/
2
- # Backend app: http://127.0.0.1:8787 (default PORT env)
2
+ # Backend app: http://127.0.0.1:8787 (default PI_WEB_PORT env)
3
3
  #
4
4
  # IMPORTANT: pi-web-ui 0.23+ checks the WebSocket Origin against the request
5
5
  # Host (hostname AND port). Every proxied location MUST forward the original