From 1ea17fc4c7d53ea0ef4630bc128c591c1dad1975 Mon Sep 17 00:00:00 2001 From: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:29:49 +0530 Subject: [PATCH] fix(windows): junctions/hardlinks for symlinks, WinRT toast notifier, DPI re-fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace-worktree: when symlinkSync EPERMs on Windows (no admin / Developer Mode), try a junction for directories and a hardlink for files before falling back to recursive cpSync. The previous fallback copied node_modules into every worktree — slow and bloated. notifier-desktop: add a win32 branch using PowerShell + WinRT toast XML (no third-party deps). The script is base64-encoded as -EncodedCommand to sidestep PowerShell argument tokenization. Toast failures log a warning instead of rejecting so a stripped-down SKU or disabled notifications can't crash the lifecycle. DirectTerminal: re-fit on devicePixelRatio changes via matchMedia. ResizeObserver doesn't fire when only DPR changes (e.g. dragging the window between monitors at different scales on Windows), leaving an unrendered stripe to the right of the last column until manual resize. Co-Authored-By: Claude Sonnet 4.6 --- .../notifier-desktop/src/index.test.ts | 66 +++++++++++++++-- .../plugins/notifier-desktop/src/index.ts | 52 ++++++++++++++ .../src/__tests__/index.test.ts | 71 ++++++++++++++++++- .../plugins/workspace-worktree/src/index.ts | 33 ++++++++- .../web/src/components/DirectTerminal.tsx | 26 +++++++ 5 files changed, 235 insertions(+), 13 deletions(-) diff --git a/packages/plugins/notifier-desktop/src/index.test.ts b/packages/plugins/notifier-desktop/src/index.test.ts index ed2b80709..6abf9bae1 100644 --- a/packages/plugins/notifier-desktop/src/index.test.ts +++ b/packages/plugins/notifier-desktop/src/index.test.ts @@ -36,11 +36,14 @@ describe("notifier-desktop", () => { beforeEach(() => { vi.clearAllMocks(); mockPlatform.mockReturnValue("darwin"); - mockExecFile.mockImplementation( - (_cmd: string, _args: string[], cb: (err: Error | null) => void) => { - cb(null); - }, - ); + mockExecFile.mockImplementation((..._args: unknown[]) => { + // execFile may be called as (cmd, args, cb) or (cmd, args, opts, cb). + // Pick whichever trailing arg is the callback so both shapes work. + const cb = _args.find((a) => typeof a === "function") as + | ((err: Error | null) => void) + | undefined; + cb?.(null); + }); }); describe("manifest", () => { @@ -220,14 +223,63 @@ describe("notifier-desktop", () => { }); }); + describe("notify on Windows", () => { + it("invokes powershell.exe with an EncodedCommand toast script", async () => { + mockPlatform.mockReturnValue("win32"); + const notifier = create(); + await notifier.notify(makeEvent({ message: "hello" })); + + expect(mockExecFile).toHaveBeenCalledWith( + "powershell.exe", + expect.arrayContaining(["-EncodedCommand"]), + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + const args = mockExecFile.mock.calls[0][1] as string[]; + const encoded = args[args.indexOf("-EncodedCommand") + 1]; + const script = Buffer.from(encoded, "base64").toString("utf16le"); + expect(script).toContain("ToastNotificationManager"); + expect(script).toContain("hello"); + }); + + it("XML-escapes title and message to prevent toast XML injection", async () => { + mockPlatform.mockReturnValue("win32"); + const notifier = create(); + await notifier.notify(makeEvent({ sessionId: "", message: 'a"&b' })); + const args = mockExecFile.mock.calls[0][1] as string[]; + const script = Buffer.from( + args[args.indexOf("-EncodedCommand") + 1], + "base64", + ).toString("utf16le"); + expect(script).toContain("<x>"); + expect(script).toContain("a"&b"); + expect(script).not.toContain(""); + }); + + it("logs a warning but does not reject when powershell fails", async () => { + mockPlatform.mockReturnValue("win32"); + mockExecFile.mockImplementationOnce((..._args: unknown[]) => { + const cb = _args.find((a) => typeof a === "function") as + | ((err: Error | null) => void) + | undefined; + cb?.(new Error("WinRT unavailable")); + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const notifier = create(); + await expect(notifier.notify(makeEvent())).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("WinRT unavailable")); + warnSpy.mockRestore(); + }); + }); + describe("notify on unsupported platform", () => { it("resolves without error on unsupported platform", async () => { - mockPlatform.mockReturnValue("win32"); + mockPlatform.mockReturnValue("freebsd"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const notifier = create(); await expect(notifier.notify(makeEvent())).resolves.toBeUndefined(); expect(mockExecFile).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not supported on win32")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not supported on freebsd")); warnSpy.mockRestore(); }); }); diff --git a/packages/plugins/notifier-desktop/src/index.ts b/packages/plugins/notifier-desktop/src/index.ts index 3df65d21c..c29e6bb0c 100644 --- a/packages/plugins/notifier-desktop/src/index.ts +++ b/packages/plugins/notifier-desktop/src/index.ts @@ -9,6 +9,36 @@ import { type EventPriority, } from "@aoagents/ao-core"; +function xmlEscape(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function buildWindowsToastScript(title: string, message: string, sound: boolean): string { + // Build the toast XML — both fields are user content, so XML-escape them. + const safeTitle = xmlEscape(title); + const safeMessage = xmlEscape(message); + const audioNode = sound ? "" : '