diff --git a/src/tray/windows-tray.ps1 b/src/tray/windows-tray.ps1 index 055387eb6b..6e843a66db 100644 --- a/src/tray/windows-tray.ps1 +++ b/src/tray/windows-tray.ps1 @@ -13,6 +13,7 @@ param( $ErrorActionPreference = "Stop" Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing +try { [System.Windows.Forms.Application]::EnableVisualStyles() } catch { $null = $_ } # Normalize aliases before deriving singleton/event names. Without this, # C:\path and C:\path\. create separate tray instances for the same home. @@ -68,6 +69,7 @@ if (-not $createdNew) { $mutex.Dispose() exit 0 } +[void]$stopEvent.Reset() $heartbeatPath = Join-Path $OpenCodexHome "tray-heartbeat.json" $actionLogPath = Join-Path $OpenCodexHome "tray-actions.log" @@ -332,11 +334,15 @@ $notify.add_DoubleClick({ Start-OcxCommand @("gui") }) $timer = New-Object System.Windows.Forms.Timer $timer.Interval = 3000 $timer.add_Tick({ - if ($stopEvent.WaitOne(0)) { - [System.Windows.Forms.Application]::Exit() - return + try { + if ($stopEvent.WaitOne(0)) { + [System.Windows.Forms.Application]::Exit() + return + } + Update-TrayState + } catch { + try { Write-ActionLog "timer tick failed: $($_.Exception.GetType().Name)" } catch { $null = $_ } } - Update-TrayState }) $notify.ContextMenuStrip = $menu $notify.Icon = $offlineIcon diff --git a/src/tray/windows.ts b/src/tray/windows.ts index cefb61c3ee..c20e0491d1 100644 --- a/src/tray/windows.ts +++ b/src/tray/windows.ts @@ -2,7 +2,7 @@ import { execFile, execFileSync, spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { join, resolve, win32 as win32Path } from "node:path"; import { expandUserPath, getConfigDir } from "../config"; import { durableBunRuntime } from "../lib/bun-runtime"; import type { BunRuntimeSource } from "../lib/bun-runtime"; @@ -46,6 +46,12 @@ export interface WindowsTrayStatus { summary: string; } +export type WindowsTrayLaunchRunner = ( + file: string, + args: readonly string[], + options: { stdio: "ignore"; windowsHide: true; timeout: number }, +) => void; + function trayStatePath(): string { return join(getConfigDir(), "tray-state.json"); } @@ -506,8 +512,10 @@ const DETACHED_TRAY_HOST_LAUNCHER = [ "$startInfo = New-Object System.Diagnostics.ProcessStartInfo", "$startInfo.FileName = $env:OCX_TRAY_HOST_BUN", "$startInfo.Arguments = $env:OCX_TRAY_HOST_ARGS", - "$startInfo.UseShellExecute = $true", + "$startInfo.UseShellExecute = $false", + "$startInfo.CreateNoWindow = $true", "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", + "$startInfo.EnvironmentVariables['OCX_TRAY_ENTRY_B64'] = $env:OCX_TRAY_ENTRY_B64", "$child = [System.Diagnostics.Process]::Start($startInfo)", "if ($null -eq $child) { throw 'Windows tray host did not start.' }", "$child.Dispose()", @@ -537,7 +545,27 @@ export function launchWindowsTrayHost(state: WindowsTrayEntry): void { }); } +export function launchInstalledWindowsTray( + launcherPath: string, + deps: { systemRoot?: string; run?: WindowsTrayLaunchRunner } = {}, +): void { + const wscript = win32Path.join(deps.systemRoot ?? process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe"); + const run = deps.run ?? ((file, args, options) => { + execFileSync(file, [...args], options); + }); + run(wscript, ["//B", "//NoLogo", safePath(launcherPath)], { + stdio: "ignore", + windowsHide: true, + timeout: 15_000, + }); +} + function spawnTray(state: WindowsTrayEntry): void { + const launcher = installedTrayLauncherPath(); + if (existsSync(launcher)) { + launchInstalledWindowsTray(launcher); + return; + } launchWindowsTrayHost(state); } diff --git a/tests/windows-tray.test.ts b/tests/windows-tray.test.ts index 7b0b026692..550f894730 100644 --- a/tests/windows-tray.test.ts +++ b/tests/windows-tray.test.ts @@ -15,6 +15,7 @@ import { buildWindowsTrayLauncherScript, buildWindowsTrayPowerShellCommand, buildWindowsTrayRunCommand, + launchInstalledWindowsTray, launchWindowsTrayHost, parseWindowsTrayRunValue, readWindowsTrayRunValueWithAsyncRunner, @@ -132,6 +133,31 @@ describe("Windows tray packaging and command safety", () => { expect(runCommand.toLowerCase()).toContain("wscript.exe"); expect(runCommand.length).toBeLessThanOrEqual(260); }); + + test("launches the installed tray through hidden wscript with bounded stdio", () => { + const calls: Array<{ + file: string; + args: readonly string[]; + options: { stdio: "ignore"; windowsHide: true; timeout: number }; + }> = []; + const launcherPath = "C:\\Users\\Test\\.opencodex\\opencodex-tray.vbs"; + + launchInstalledWindowsTray(launcherPath, { + systemRoot: "C:\\Windows", + run: (file, args, options) => { calls.push({ file, args, options }); }, + }); + + expect(calls).toEqual([{ + file: "C:\\Windows\\System32\\wscript.exe", + args: ["//B", "//NoLogo", launcherPath], + options: { + stdio: "ignore", + windowsHide: true, + timeout: 15_000, + }, + }]); + }); + test("keeps UNC backslashes literal in the VBS Run command", () => { const uncRoot = "\\\\server\\share"; const uncEntry: WindowsTrayEntry = { @@ -306,9 +332,16 @@ describe("Windows tray packaging and command safety", () => { const cli = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); expect(typescript).not.toContain("\u0000"); expect(typescript).toContain("OCX_TRAY_ENTRY_B64"); - expect(typescript).toContain("$startInfo.UseShellExecute = $true"); + expect(typescript).not.toContain("$startInfo.UseShellExecute = $true"); + expect(typescript).toContain("$startInfo.UseShellExecute = $false"); + expect(typescript).toContain("$startInfo.CreateNoWindow = $true"); + expect(typescript).toContain("$startInfo.EnvironmentVariables['OCX_TRAY_ENTRY_B64'] = $env:OCX_TRAY_ENTRY_B64"); expect(source).toContain("System.Threading.Mutex"); expect(source).toContain("System.Threading.EventWaitHandle"); + expect(source).toContain("[System.Windows.Forms.Application]::EnableVisualStyles()"); + expect(source.indexOf("[void]$stopEvent.Reset()")).toBeGreaterThan(source.indexOf("if (-not $createdNew)")); + expect(source).toMatch(/\$timer\.add_Tick\(\{\s*try \{/); + expect(source).toContain('Write-ActionLog "timer tick failed: $($_.Exception.GetType().Name)"'); expect(source).toContain("GetFullPath"); expect(source).toContain("GetPathRoot"); expect(source).toContain("$heartbeat.hostPid = $HostPid");