fix(windows): junctions/hardlinks for symlinks, WinRT toast notifier, DPI re-fit
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 <noreply@anthropic.com>
This commit is contained in:
parent
7d40f19abd
commit
1ea17fc4c7
|
|
@ -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: "<x>", 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("<x>");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, """)
|
||||
.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 ? "" : '<audio silent="true" />';
|
||||
const xml = `<toast>${audioNode}<visual><binding template="ToastGeneric"><text>${safeTitle}</text><text>${safeMessage}</text></binding></visual></toast>`;
|
||||
|
||||
// PowerShell script — uses WinRT directly (no BurntToast dep). The XML is
|
||||
// injected as a single-quoted PS string with embedded apostrophes doubled.
|
||||
const psSafeXml = xml.replace(/'/g, "''");
|
||||
return [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
|
||||
"[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]",
|
||||
"$xml = New-Object Windows.Data.Xml.Dom.XmlDocument",
|
||||
`$xml.LoadXml('${psSafeXml}')`,
|
||||
"$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
|
||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Agent Orchestrator').Show($toast)",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
export const manifest = {
|
||||
name: "desktop",
|
||||
slot: "notifier" as const,
|
||||
|
|
@ -80,6 +110,28 @@ function sendNotification(
|
|||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
} else if (os === "win32") {
|
||||
// WinRT toast via PowerShell — no third-party deps. Encode the script
|
||||
// as UTF-16LE base64 so we never fight with PowerShell's argument
|
||||
// tokenizer over quotes, special chars, or newlines in the toast XML.
|
||||
const script = buildWindowsToastScript(title, message, options.sound);
|
||||
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
||||
execFile(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
||||
{ windowsHide: true, timeout: 10_000 },
|
||||
(err) => {
|
||||
if (err) {
|
||||
// Don't crash the lifecycle on toast failures — log and resolve.
|
||||
// Common causes: stripped-down Windows SKU without WinRT, locked
|
||||
// group policy, or the user disabled toast notifications.
|
||||
console.warn(
|
||||
`[notifier-desktop] Windows toast failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.warn(`[notifier-desktop] Desktop notifications not supported on ${os}`);
|
||||
resolve();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ vi.mock("node:child_process", () => {
|
|||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
symlinkSync: vi.fn(),
|
||||
linkSync: vi.fn(),
|
||||
cpSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
|
|
@ -47,7 +49,9 @@ import * as childProcess from "node:child_process";
|
|||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
linkSync,
|
||||
cpSync,
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
|
|
@ -66,7 +70,9 @@ const mockExecFileAsync = (childProcess.execFile as any)[
|
|||
|
||||
const mockExistsSync = existsSync as ReturnType<typeof vi.fn>;
|
||||
const mockLstatSync = lstatSync as ReturnType<typeof vi.fn>;
|
||||
const mockStatSync = statSync as ReturnType<typeof vi.fn>;
|
||||
const mockSymlinkSync = symlinkSync as ReturnType<typeof vi.fn>;
|
||||
const mockLinkSync = linkSync as ReturnType<typeof vi.fn>;
|
||||
const mockCpSync = cpSync as ReturnType<typeof vi.fn>;
|
||||
const mockRmSync = rmSync as ReturnType<typeof vi.fn>;
|
||||
const mockMkdirSync = mkdirSync as ReturnType<typeof vi.fn>;
|
||||
|
|
@ -986,13 +992,41 @@ describe("workspace.postCreate()", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("falls back to cpSync when symlinkSync fails on Windows (B19)", async () => {
|
||||
it("falls back to junction for directories on Windows (B19)", async () => {
|
||||
const ws = create();
|
||||
// Use workspaceInfo as-is — path check now uses sep so it works on all platforms
|
||||
const project = makeProject({ symlinks: ["node_modules"] });
|
||||
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockExistsSync.mockReturnValueOnce(true); // sourcePath exists
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
mockLstatSync.mockImplementationOnce(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
||||
const symlinkError = Object.assign(new Error("symlink requires elevation"), { code: "EPERM" });
|
||||
mockSymlinkSync
|
||||
.mockImplementationOnce(() => {
|
||||
throw symlinkError;
|
||||
})
|
||||
.mockImplementationOnce(() => undefined); // junction succeeds
|
||||
mockStatSync.mockReturnValueOnce({ isDirectory: () => true });
|
||||
|
||||
await ws.postCreate!(workspaceInfo, project);
|
||||
|
||||
expect(mockSymlinkSync).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining("node_modules"),
|
||||
expect.stringContaining("node_modules"),
|
||||
"junction",
|
||||
);
|
||||
expect(mockLinkSync).not.toHaveBeenCalled();
|
||||
expect(mockCpSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to hardlink for files on Windows (B19)", async () => {
|
||||
const ws = create();
|
||||
const project = makeProject({ symlinks: [".env"] });
|
||||
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
mockLstatSync.mockImplementationOnce(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
|
@ -1001,6 +1035,37 @@ describe("workspace.postCreate()", () => {
|
|||
mockSymlinkSync.mockImplementationOnce(() => {
|
||||
throw symlinkError;
|
||||
});
|
||||
mockStatSync.mockReturnValueOnce({ isDirectory: () => false });
|
||||
mockLinkSync.mockImplementationOnce(() => undefined);
|
||||
|
||||
await ws.postCreate!(workspaceInfo, project);
|
||||
|
||||
expect(mockLinkSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(".env"),
|
||||
expect.stringContaining(".env"),
|
||||
);
|
||||
expect(mockCpSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to cpSync when junction also fails on Windows (B19)", async () => {
|
||||
const ws = create();
|
||||
const project = makeProject({ symlinks: ["node_modules"] });
|
||||
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockExistsSync.mockReturnValueOnce(true);
|
||||
mockLstatSync.mockImplementationOnce(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
||||
const symlinkError = Object.assign(new Error("symlink requires elevation"), { code: "EPERM" });
|
||||
mockSymlinkSync
|
||||
.mockImplementationOnce(() => {
|
||||
throw symlinkError;
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("junction failed");
|
||||
});
|
||||
mockStatSync.mockReturnValueOnce({ isDirectory: () => true });
|
||||
|
||||
await ws.postCreate!(workspaceInfo, project);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import * as fs from "node:fs";
|
||||
import { existsSync, lstatSync, symlinkSync, rmSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
linkSync,
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve, basename, dirname, sep } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
|
|
@ -410,8 +419,26 @@ export function create(config?: Record<string, unknown>): Workspace {
|
|||
symlinkSync(sourcePath, targetPath);
|
||||
} catch (err) {
|
||||
if (isWindows()) {
|
||||
// Symlinks require admin/Developer Mode on Windows — fall back to copy
|
||||
fs.cpSync(sourcePath, targetPath, { recursive: true });
|
||||
// Symlinks need admin/Developer Mode on Windows. Try unprivileged
|
||||
// alternatives first — junctions for dirs, hardlinks for files —
|
||||
// before falling back to a recursive copy (slow + bloats every
|
||||
// worktree, especially for node_modules).
|
||||
const isDir = (() => {
|
||||
try {
|
||||
return statSync(sourcePath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
if (isDir) {
|
||||
symlinkSync(sourcePath, targetPath, "junction");
|
||||
} else {
|
||||
linkSync(sourcePath, targetPath);
|
||||
}
|
||||
} catch {
|
||||
fs.cpSync(sourcePath, targetPath, { recursive: true });
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -417,6 +417,31 @@ export function DirectTerminal({
|
|||
resizeObserver.observe(terminalRef.current);
|
||||
}
|
||||
|
||||
// Re-fit on devicePixelRatio changes (Windows display scaling, dragging
|
||||
// window between monitors with different DPI). ResizeObserver doesn't
|
||||
// fire for DPR-only changes, so we listen via matchMedia. Without this,
|
||||
// moving the window to a 125%/150%-scaled monitor leaves a stripe of
|
||||
// unrendered background to the right of the last column.
|
||||
let dprMedia: MediaQueryList | null = null;
|
||||
const handleDprChange = () => {
|
||||
if (!mounted || !fitAddon.current || !terminalInstance.current) return;
|
||||
try {
|
||||
terminalInstance.current.clearTextureAtlas?.();
|
||||
fitAddon.current.fit();
|
||||
resizeTerminalMux(
|
||||
sessionId,
|
||||
terminalInstance.current.cols,
|
||||
terminalInstance.current.rows,
|
||||
);
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
};
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
dprMedia = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
||||
dprMedia.addEventListener?.("change", handleDprChange);
|
||||
}
|
||||
|
||||
// ── Preserve selection while terminal receives output ────────
|
||||
// xterm.js clears the selection on every terminal.write(). We
|
||||
// buffer incoming data while a selection is active so the
|
||||
|
|
@ -539,6 +564,7 @@ export function DirectTerminal({
|
|||
cleanup = () => {
|
||||
clearTimeout(deferredFitTimeout);
|
||||
resizeObserver?.disconnect();
|
||||
dprMedia?.removeEventListener?.("change", handleDprChange);
|
||||
cleanupTouchScroll();
|
||||
selectionDisposable.dispose();
|
||||
if (safetyTimer) clearTimeout(safetyTimer);
|
||||
|
|
|
|||
Loading…
Reference in New Issue