diff --git a/bin/cli/tray/autostart.mjs b/bin/cli/tray/autostart.mjs index 605161c2a1..b8318f2d79 100644 --- a/bin/cli/tray/autostart.mjs +++ b/bin/cli/tray/autostart.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; const APP_LABEL = "com.omniroute.autostart"; const WIN_REG_VALUE = "OmniRoute"; +const WIN_STARTUP_FILE = "OmniRoute.vbs"; const LINUX_SERVICE_NAME = "omniroute.service"; const LINUX_DESKTOP_NAME = "omniroute.desktop"; @@ -323,38 +324,101 @@ function isEnabledMac() { ); } +/** + * Returns the absolute path to the Windows Startup folder + * (%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\), + * or null when APPDATA is not set. + */ +function winStartupDir() { + if (!process.env.APPDATA) return null; + return join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"); +} + +/** + * Returns the full path to the VBS autostart script in the Startup folder, + * or null when the Startup folder cannot be resolved. + */ +function winStartupPath() { + const dir = winStartupDir(); + return dir ? join(dir, WIN_STARTUP_FILE) : null; +} + +/** + * Builds the VBScript source that launches OmniRoute with WSH's Run method + * using SW_HIDE (0) so no console window appears. + * + * 9Router uses the same pattern: a .vbs file in the Startup folder that calls + * WScript.Shell.Run with window style 0 (hidden). This avoids the console + * window flash that HKCU\Run causes for console-mode node.exe. + */ +function buildWinVbsContent(cliPath) { + const execLine = buildServeExecLine(cliPath, { tray: true }); + // VBScript doubles `"` inside a string to escape them. The execLine already + // contains quoted paths; escape each `"` to `""` so the VBS parser reads + // them as literal quote characters in the command string passed to Run(). + const vbsSafe = execLine.replace(/"/g, '""'); + return [ + 'Set WshShell = CreateObject("WScript.Shell")', + `WshShell.Run "${vbsSafe}", 0, False`, + "", + ].join("\n"); +} + +/** + * Removes the legacy HKCU\Run registry value if present. Callers use this + * during migration so stale Run entries don't linger after switching to the + * VBS-based autostart. + */ +function cleanLegacyWinReg() { + try { + execSync( + `reg delete HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /f 2>nul`, + { stdio: "ignore", windowsHide: true } + ); + } catch { + // entry did not exist or already removed — noop + } +} + function enableWin() { const cliPath = resolveCliPath(); if (!cliPath) return false; - const value = `"${process.execPath}" "${cliPath}" serve --tray --no-open`; - try { - execSync( - `reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /t REG_SZ /d "${value}" /f`, - { stdio: "ignore", windowsHide: true } - ); - return true; - } catch { - return false; - } + const vbsPath = winStartupPath(); + if (!vbsPath) return false; + const dir = dirname(vbsPath); + mkdirSync(dir, { recursive: true }); + writeFileSync(vbsPath, buildWinVbsContent(cliPath), { mode: 0o644 }); + // Migrate away from the legacy HKCU\Run entry — it launches node.exe with + // a visible console window. The VBS approach is the replacement. + cleanLegacyWinReg(); + return existsSync(vbsPath); } function disableWin() { + const vbsPath = winStartupPath(); + if (!vbsPath) return false; try { - execSync( - `reg delete HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /f`, - { stdio: "ignore", windowsHide: true } - ); - return true; + unlinkSync(vbsPath); } catch { - return false; + // already removed } + // Also clean up any lingering legacy registry entry as a safety net. + cleanLegacyWinReg(); + return !existsSync(vbsPath); } function isEnabledWin() { + const vbsPath = winStartupPath(); + if (!vbsPath) return false; + // Primary check: VBS file exists in the Startup folder. + if (existsSync(vbsPath)) return true; + // Fallback check: legacy HKCU\Run entry (for users who enabled autostart + // before the VBS migration). Treat it as enabled so the tray menu shows + // the correct toggle state, and calling disable() will clean it up. try { const out = execSync( `reg query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE}`, - { stdio: "pipe", windowsHide: true, encoding: "utf8" } + { stdio: "pipe", windowsHide: true, encoding: "utf8", timeout: 3000 } ); return out.includes(WIN_REG_VALUE); } catch { diff --git a/changelog.d/fixes/7925-windows-autostart-vbs.md b/changelog.d/fixes/7925-windows-autostart-vbs.md new file mode 100644 index 0000000000..f892a979c7 --- /dev/null +++ b/changelog.d/fixes/7925-windows-autostart-vbs.md @@ -0,0 +1 @@ +- **fix(autostart):** Windows auto-start no longer flashes a console window — writes a `.vbs` launcher to the Startup folder instead of the `HKCU\Run` registry key, adopting the same approach as 9Router ([#7925](https://github.com/diegosouzapw/OmniRoute/pull/7925)) diff --git a/tests/unit/cli/autostart-windows.test.ts b/tests/unit/cli/autostart-windows.test.ts new file mode 100644 index 0000000000..75cbd5af8f --- /dev/null +++ b/tests/unit/cli/autostart-windows.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +let tmpDir: string; +let origAppData: string | undefined; + +test.before(() => { + tmpDir = mkdtempSync(join(tmpdir(), "omniroute-autostart-win-")); + origAppData = process.env.APPDATA; + process.env.APPDATA = tmpDir; +}); + +test.after(() => { + if (origAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = origAppData; + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} +}); + +// --------------------------------------------------------------------------- +// Integration test — runs only on win32 because enable() dispatches on +// process.platform. Sets APPDATA to a temp dir so the VBS is written to a +// clean, disposable Startup folder. +// --------------------------------------------------------------------------- + +test("Windows enable/disable writes and removes VBS in Startup folder", async () => { + if (process.platform !== "win32") return; + + const { enable, disable, isAutostartEnabled } = + await import("../../../bin/cli/tray/autostart.mjs"); + + const startupDir = join(tmpDir, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"); + const vbsPath = join(startupDir, "OmniRoute.vbs"); + + // Should start clean. + assert.equal(existsSync(vbsPath), false); + + const ok = enable(); + assert.equal(typeof ok, "boolean"); + assert.equal(ok, true); + + // VBS file must exist with correct content. + assert.equal(existsSync(vbsPath), true); + const vbs = readFileSync(vbsPath, "utf8"); + assert.match(vbs, /WScript\.Shell/, "creates WScript.Shell COM object"); + assert.match(vbs, /WshShell\.Run/, "calls Run method"); + assert.match(vbs, /serve --no-open --tray/, "launches OmniRoute tray server"); + assert.match(vbs, /, 0, False/, "uses SW_HIDE (0) and no wait"); + assert.equal(vbs.endsWith("\n"), true, "trailing newline"); + + assert.equal(isAutostartEnabled(), true); + + // Disable and verify cleanup. + const disabled = disable(); + assert.equal(typeof disabled, "boolean"); + assert.equal(disabled, true); + assert.equal(existsSync(vbsPath), false); + assert.equal(isAutostartEnabled(), false); +}); + +// --------------------------------------------------------------------------- +// Source-level assertions (run on any platform) — verify the implementation +// uses the VBS Startup-folder approach instead of the old Registry Run key. +// Mirrors the pattern used by autostart-linux.test.ts and +// autostart-macos-launchctl.test.ts. +// --------------------------------------------------------------------------- + +test("Windows enableWin writes VBS to Startup folder, not reg add", () => { + const source = readFileSync(join(process.cwd(), "bin/cli/tray/autostart.mjs"), "utf8"); + + // Helper functions for the new VBS approach. + assert.match(source, /winStartupDir/); + assert.match(source, /winStartupPath/); + assert.match(source, /buildWinVbsContent/); + + // VBS-runner scaffolding. + assert.match(source, /WScript\.Shell/); + assert.match(source, /WshShell\.Run/); + + // The old approach used `reg add ... /v WIN_REG_VALUE`. + assert.doesNotMatch(source, /reg add.*WIN_REG_VALUE/); +}); + +test("Windows disableWin deletes VBS and cleans legacy registry", () => { + const source = readFileSync(join(process.cwd(), "bin/cli/tray/autostart.mjs"), "utf8"); + + assert.match(source, /unlinkSync\(vbsPath\)/); + assert.match(source, /cleanLegacyWinReg\(\)/); +}); + +test("Windows isEnabledWin checks VBS first, falls back to legacy reg", () => { + const source = readFileSync(join(process.cwd(), "bin/cli/tray/autostart.mjs"), "utf8"); + + // Primary check is VBS file existence. + assert.match(source, /existsSync\(vbsPath\)/); + + // Fallback for users still on the old Registry Run key. + assert.match(source, /reg query.*WIN_REG_VALUE/); +});