Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions src/tray/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,17 +414,43 @@ function assertWindows(): void {
if (process.platform !== "win32") throw new Error(`The opencodex tray is Windows-only (current platform: ${process.platform}).`);
}

function spawnTray(state: WindowsTrayEntry): void {
const child = spawn(state.bun, [state.cli, "__tray-host"], {
detached: true,
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.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden",
"$child = [System.Diagnostics.Process]::Start($startInfo)",
"if ($null -eq $child) { throw 'Windows tray host did not start.' }",
"$child.Dispose()",
].join("; ");

const DETACHED_TRAY_HOST_LAUNCHER_B64 = Buffer.from(DETACHED_TRAY_HOST_LAUNCHER, "utf16le").toString("base64");

export function launchWindowsTrayHost(state: WindowsTrayEntry): void {
const bun = safePath(state.bun);
const cli = safePath(state.cli);
execFileSync(windowsPowerShellPath(), [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
DETACHED_TRAY_HOST_LAUNCHER_B64,
], {
stdio: "ignore",
windowsHide: true,
timeout: 15_000,
env: {
...process.env,
OCX_TRAY_HOST_BUN: bun,
OCX_TRAY_HOST_ARGS: `${quoteRunValue(cli)} __tray-host`,
OCX_TRAY_ENTRY_B64: Buffer.from(JSON.stringify(state), "utf8").toString("base64"),
},
});
child.unref();
}

function spawnTray(state: WindowsTrayEntry): void {
launchWindowsTrayHost(state);
}

function parseTrayHostEntry(): WindowsTrayEntry {
Expand All @@ -443,6 +469,8 @@ export async function runWindowsTrayHost(): Promise<void> {
assertWindows();
const entry = parseTrayHostEntry();
delete process.env.OCX_TRAY_ENTRY_B64;
delete process.env.OCX_TRAY_HOST_BUN;
delete process.env.OCX_TRAY_HOST_ARGS;
const child = spawn(windowsPowerShellPath(), windowsTrayProcessArgs(entry, "Run", process.pid), {
stdio: "ignore",
windowsHide: true,
Expand Down
7 changes: 7 additions & 0 deletions tests/helpers/windows-tray-inheritance-child.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { writeFileSync } from "node:fs";

const pidPath = process.env.OCX_TRAY_TEST_PID_FILE;
if (!pidPath) throw new Error("Missing OCX_TRAY_TEST_PID_FILE.");

writeFileSync(pidPath, String(process.pid));
await Bun.sleep(30_000);
57 changes: 55 additions & 2 deletions tests/windows-tray.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildWindowsTrayRunCommand,
launchWindowsTrayHost,
parseWindowsTrayRunValue,
readWindowsTrayRunValueWithAsyncRunner,
readWindowsTrayRunValueWithRunner,
Expand Down Expand Up @@ -195,7 +197,7 @@ describe("Windows tray packaging and command safety", () => {
const source = readFileSync(join(import.meta.dir, "..", "src", "tray", "windows-tray.ps1"), "utf8");
expect(typescript).not.toContain("\u0000");
expect(typescript).toContain("OCX_TRAY_ENTRY_B64");
expect(typescript).toContain('detached: true');
expect(typescript).toContain("$startInfo.UseShellExecute = $true");
expect(source).toContain("System.Threading.Mutex");
expect(source).toContain("System.Threading.EventWaitHandle");
expect(source).toContain("GetFullPath");
Expand All @@ -212,6 +214,57 @@ describe("Windows tray packaging and command safety", () => {
expect(source).not.toContain("Stop-Process");
});

test("launches the detached tray host without retaining the proxy listen socket", async () => {
if (process.platform !== "win32") return;
const directory = mkdtempSync(join(tmpdir(), "ocx-tray-inheritance-"));
const pidPath = join(directory, "child.pid");
const childPath = join(directory, "child & %TEMP% 테스트.ts");
copyFileSync(join(import.meta.dir, "helpers", "windows-tray-inheritance-child.ts"), childPath);
const previousPidPath = process.env.OCX_TRAY_TEST_PID_FILE;
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => new Response("ok"),
});
const port = server.port;
let childPid = 0;
let replacement: ReturnType<typeof Bun.serve> | undefined;

try {
process.env.OCX_TRAY_TEST_PID_FILE = pidPath;
launchWindowsTrayHost({
...entry,
bun: process.execPath,
cli: childPath,
});
for (let attempt = 0; attempt < 100 && !existsSync(pidPath); attempt += 1) {
await Bun.sleep(25);
}
expect(existsSync(pidPath)).toBe(true);
childPid = Number(readFileSync(pidPath, "utf8"));
expect(Number.isSafeInteger(childPid) && childPid > 0).toBe(true);
expect(() => process.kill(childPid, 0)).not.toThrow();

await server.stop(true);
replacement = Bun.serve({
hostname: "127.0.0.1",
port,
fetch: () => new Response("replacement"),
});
expect(replacement.port).toBe(port);
expect(() => process.kill(childPid, 0)).not.toThrow();
} finally {
if (previousPidPath === undefined) delete process.env.OCX_TRAY_TEST_PID_FILE;
else process.env.OCX_TRAY_TEST_PID_FILE = previousPidPath;
if (replacement) await replacement.stop(true);
await server.stop(true);
if (childPid > 0) {
try { process.kill(childPid); } catch { /* exact test child already exited */ }
}
rmSync(directory, { recursive: true, force: true });
}
});

test("ships branded multi-size Windows tray icons", () => {
const assets = join(import.meta.dir, "..", "src", "tray", "assets");
for (const name of ["online", "warning", "offline"]) {
Expand Down
Loading