From 1a6599437b6ad77330923819613cc28be3b33945 Mon Sep 17 00:00:00 2001
From: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Date: Fri, 14 Aug 2026 17:58:49 +0200
Subject: [PATCH 1/5] fix(web): clarify desktop update status (#6504)
---
.../sidebar/DesktopUpdateStatusIcon.tsx | 126 ++++++++++++++++++
.../components/sidebar/SidebarUpdatePill.tsx | 113 +++++++++++++---
2 files changed, 217 insertions(+), 22 deletions(-)
create mode 100644 apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx
diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx
new file mode 100644
index 000000000..9346a742a
--- /dev/null
+++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx
@@ -0,0 +1,126 @@
+import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react";
+import type { AnimationEventHandler } from "react";
+
+import { cn } from "../../lib/utils";
+
+const DOWNLOAD_PROGRESS_RADIUS = 14;
+const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS;
+
+export type DesktopUpdateStatusIconState =
+ | "idle"
+ | "checking"
+ | "available"
+ | "downloading"
+ | "downloaded";
+
+function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {
+ if (percent === null || !Number.isFinite(percent)) return 0;
+ return Math.min(100, Math.max(0, percent));
+}
+
+export function shouldShowDesktopUpdateCheckIcon({
+ isAnimationLatched,
+ isChecking,
+ prefersReducedMotion,
+}: {
+ readonly isAnimationLatched: boolean;
+ readonly isChecking: boolean;
+ readonly prefersReducedMotion: boolean;
+}): boolean {
+ return isChecking || (isAnimationLatched && !prefersReducedMotion);
+}
+
+export function shouldContinueDesktopUpdateCheckAnimation({
+ isChecking,
+ prefersReducedMotion,
+}: {
+ readonly isChecking: boolean;
+ readonly prefersReducedMotion: boolean;
+}): boolean {
+ return isChecking && !prefersReducedMotion;
+}
+
+function DesktopUpdateAvailableIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function DesktopUpdateDownloadingIcon({ percent }: { readonly percent: number | null }) {
+ const normalizedPercent = normalizeDesktopUpdateDownloadPercent(percent);
+ const progressOffset = DOWNLOAD_PROGRESS_CIRCUMFERENCE * (1 - normalizedPercent / 100);
+
+ return (
+
+
+
+
+ );
+}
+
+function DesktopUpdateDownloadedIcon() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function DesktopUpdateStatusIcon({
+ downloadPercent,
+ isCheckAnimating,
+ onCheckAnimationIteration,
+ status,
+}: {
+ readonly downloadPercent?: number | null;
+ readonly isCheckAnimating?: boolean;
+ readonly onCheckAnimationIteration?: AnimationEventHandler;
+ readonly status: DesktopUpdateStatusIconState;
+}) {
+ if (status === "available") return ;
+ if (status === "downloading") {
+ return ;
+ }
+ if (status === "downloaded") return ;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
index 191f30438..c5cffd811 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
@@ -1,6 +1,7 @@
-import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
-import { useCallback, useState } from "react";
+import { TriangleAlertIcon } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
import { isElectron } from "../../env";
+import { useMediaQuery } from "../../hooks/useMediaQuery";
import { cn } from "../../lib/utils";
import { ensureLocalApi } from "../../localApi";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
@@ -21,6 +22,38 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Separator } from "../ui/separator";
import { SidebarMenuItem } from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import {
+ DesktopUpdateStatusIcon,
+ shouldContinueDesktopUpdateCheckAnimation,
+ shouldShowDesktopUpdateCheckIcon,
+} from "./DesktopUpdateStatusIcon";
+
+function resolveSidebarUpdatePresentation({
+ action,
+ isDownloading,
+ showCheckIcon,
+}: {
+ readonly action: ReturnType;
+ readonly isDownloading: boolean;
+ readonly showCheckIcon: boolean;
+}) {
+ const showUpdateDetails = action !== "none" || isDownloading;
+ const iconStatus = showCheckIcon
+ ? "checking"
+ : action === "install"
+ ? "downloaded"
+ : isDownloading
+ ? "downloading"
+ : action === "download"
+ ? "available"
+ : "idle";
+
+ return {
+ iconStatus,
+ showUpdateDetails,
+ showUpdateIconState: showUpdateDetails && !showCheckIcon,
+ } as const;
+}
function keyReleaseNoteItems(items: ReadonlyArray) {
const occurrences = new Map();
@@ -110,18 +143,42 @@ export function SidebarUpdatePill() {
function SidebarUpdateControl() {
const state = useDesktopUpdateState();
const [isActionPending, setIsActionPending] = useState(false);
+ const [checkAnimationKey, setCheckAnimationKey] = useState(0);
+ const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
+ const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
+
+ useEffect(() => {
+ if (prefersReducedMotion) {
+ setIsCheckAnimationLatched(false);
+ } else if (state?.status === "checking") {
+ setIsCheckAnimationLatched(true);
+ }
+ }, [prefersReducedMotion, state?.status]);
const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
const isDownloading = state?.status === "downloading";
- const isUpdateState = action !== "none" || isDownloading;
- const tooltip = isUpdateState
+ const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
+ isAnimationLatched: isCheckAnimationLatched,
+ isChecking: state?.status === "checking",
+ prefersReducedMotion,
+ });
+ const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
+ action,
+ isDownloading,
+ showCheckIcon,
+ });
+ const tooltip = showUpdateDetails
? state
? getDesktopUpdateButtonTooltip(state)
: "Update available"
- : state?.status === "checking"
+ : showCheckIcon
? "Checking for updates…"
: "Check for updates";
- const disabled = isUpdateState ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state);
+ const disabled = showCheckIcon
+ ? true
+ : showUpdateDetails
+ ? isDesktopUpdateButtonDisabled(state)
+ : !canCheckForUpdate(state);
const handleAction = useCallback(async () => {
const bridge = window.desktopBridge;
@@ -209,6 +266,10 @@ function SidebarUpdateControl() {
return;
}
+ if (!prefersReducedMotion) {
+ setIsCheckAnimationLatched(true);
+ setCheckAnimationKey((key) => key + 1);
+ }
void bridge
.checkForUpdate()
.then((result) => {
@@ -232,7 +293,16 @@ function SidebarUpdateControl() {
);
})
.finally(() => setIsActionPending(false));
- }, [action, disabled, isActionPending, state]);
+ }, [action, disabled, isActionPending, prefersReducedMotion, state]);
+
+ const handleCheckAnimationIteration = useCallback(() => {
+ setIsCheckAnimationLatched(
+ shouldContinueDesktopUpdateCheckAnimation({
+ isChecking: state?.status === "checking",
+ prefersReducedMotion,
+ }),
+ );
+ }, [prefersReducedMotion, state?.status]);
return (
@@ -245,29 +315,28 @@ function SidebarUpdateControl() {
aria-disabled={disabled || isActionPending || undefined}
disabled={disabled || isActionPending}
className={cn(
- "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60",
- isUpdateState
+ "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed",
+ showUpdateIconState
? "bg-update-surface text-update-foreground enabled:hover:bg-update/12"
: "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground",
+ disabled && !showUpdateIconState && "opacity-60",
)}
onClick={handleAction}
>
- {action === "install" ? (
-
- ) : isUpdateState ? (
-
- ) : (
-
- )}
+
}
/>
0
+ showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0
? // pointer-events-auto overrides the positioner's pointer-events-none so the
// release notes stay open (and scrollable) when the cursor moves into them.
"pointer-events-auto max-w-none text-balance"
@@ -275,7 +344,7 @@ function SidebarUpdateControl() {
}
side="top"
style={
- isUpdateState
+ showUpdateDetails
? {
background:
"color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))",
@@ -283,9 +352,9 @@ function SidebarUpdateControl() {
}
: undefined
}
- variant={isUpdateState ? "glass" : "default"}
+ variant={showUpdateDetails ? "glass" : "default"}
>
- {isUpdateState && state ? (
+ {showUpdateDetails && state ? (
) : (
tooltip
From 80991402dcdc488838fb4d8b21171bd38d9ab0aa Mon Sep 17 00:00:00 2001
From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com>
Date: Fri, 14 Aug 2026 10:32:25 -0700
Subject: [PATCH 2/5] fix(server): terminal subprocess polling no longer floods
the PID space (#6377)
---
apps/server/src/terminal/Manager.test.ts | 120 +++++++
apps/server/src/terminal/Manager.ts | 396 ++++++++++-------------
2 files changed, 299 insertions(+), 217 deletions(-)
diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts
index ed25a0880..47d91e451 100644
--- a/apps/server/src/terminal/Manager.test.ts
+++ b/apps/server/src/terminal/Manager.test.ts
@@ -24,6 +24,7 @@ import * as Ref from "effect/Ref";
import * as Schedule from "effect/Schedule";
import * as Scope from "effect/Scope";
import * as TestClock from "effect/testing/TestClock";
+import { ChildProcessSpawner } from "effect/unstable/process";
import { expect } from "vite-plus/test";
import * as ProcessRunner from "../processRunner.ts";
@@ -953,6 +954,125 @@ it.layer(
}),
);
+ it.effect("derives subprocess activity for every terminal from one shared process snapshot", () =>
+ Effect.gen(function* () {
+ const runCalls: Array<{ command: string; args: ReadonlyArray }> = [];
+ // FakePtyAdapter assigns pids starting at 9000, so the two terminals
+ // opened below run as pids 9000 and 9001.
+ const psStdout = [" 100 9000 vim", " 101 100 git", " 200 9001 /usr/bin/python3"].join(
+ "\n",
+ );
+ const processRunner: ProcessRunner.ProcessRunner["Service"] = {
+ run: (input) =>
+ Effect.sync(() => {
+ runCalls.push({ command: input.command, args: input.args });
+ return {
+ stdout: psStdout,
+ stderr: "",
+ code: ChildProcessSpawner.ExitCode(0),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrTruncated: false,
+ stdoutInvalidUtf8: false,
+ stderrInvalidUtf8: false,
+ };
+ }),
+ };
+
+ const { manager, getEvents } = yield* createManager(5, {
+ subprocessPollIntervalMs: 20,
+ }).pipe(
+ Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
+ Effect.provide(withHostPlatform("linux")),
+ );
+
+ yield* manager.open(openInput());
+ yield* manager.open(openInput({ threadId: "thread-2" }));
+
+ yield* waitFor(
+ Effect.map(
+ getEvents,
+ (events) =>
+ events.some(
+ (event) =>
+ event.type === "activity" &&
+ event.hasRunningSubprocess === true &&
+ event.label === "vim",
+ ) &&
+ events.some(
+ (event) =>
+ event.type === "activity" &&
+ event.hasRunningSubprocess === true &&
+ event.label === "python3",
+ ),
+ ),
+ "1200 millis",
+ );
+ yield* waitFor(
+ Effect.sync(() => runCalls.length >= 3),
+ "1200 millis",
+ );
+
+ // Every spawn is the shared table snapshot — no per-terminal `pgrep`
+ // or per-child `ps -p` invocations.
+ expect(runCalls.every((call) => call.args.join(" ") === "-eo pid=,ppid=,comm=")).toBe(true);
+ }),
+ );
+
+ it.effect("keeps last known subprocess state when the process snapshot fails", () =>
+ Effect.gen(function* () {
+ let failSnapshots = false;
+ let failedCalls = 0;
+ const processRunner: ProcessRunner.ProcessRunner["Service"] = {
+ run: () =>
+ Effect.sync(() => {
+ if (failSnapshots) failedCalls += 1;
+ return {
+ stdout: failSnapshots ? "" : " 100 9000 vim",
+ stderr: "",
+ code: ChildProcessSpawner.ExitCode(failSnapshots ? 1 : 0),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrTruncated: false,
+ stdoutInvalidUtf8: false,
+ stderrInvalidUtf8: false,
+ };
+ }),
+ };
+
+ const { manager, getEvents } = yield* createManager(5, {
+ subprocessPollIntervalMs: 20,
+ }).pipe(
+ Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
+ Effect.provide(withHostPlatform("linux")),
+ );
+
+ yield* manager.open(openInput());
+ yield* waitFor(
+ Effect.map(getEvents, (events) =>
+ events.some(
+ (event) =>
+ event.type === "activity" &&
+ event.hasRunningSubprocess === true &&
+ event.label === "vim",
+ ),
+ ),
+ "1200 millis",
+ );
+
+ failSnapshots = true;
+ yield* waitFor(
+ Effect.sync(() => failedCalls >= 3),
+ "1200 millis",
+ );
+
+ // A failed snapshot is not authoritative: no terminal flips to idle.
+ const activityEvents = (yield* getEvents).filter((event) => event.type === "activity");
+ expect(activityEvents.length).toBeGreaterThan(0);
+ expect(activityEvents.every((event) => event.hasRunningSubprocess === true)).toBe(true);
+ }),
+ );
+
it.effect("caps persisted history to configured line limit", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager(3);
diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts
index 6dc9e1892..64c2dbb91 100644
--- a/apps/server/src/terminal/Manager.ts
+++ b/apps/server/src/terminal/Manager.ts
@@ -89,12 +89,21 @@ class TerminalSubprocessCheckError extends Schema.TaggedErrorClass detail !== null)
+ .join(", ");
+ return `Failed to inspect terminal subprocesses with ${this.command}${details.length > 0 ? ` (${details})` : ""}`;
}
}
@@ -610,125 +619,102 @@ function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean {
);
}
-function parseFirstChildPidFromPgrep(stdout: string): number | null {
+interface TerminalProcessTableSnapshot {
+ readonly childrenByParent: ReadonlyMap>;
+ readonly commandById: ReadonlyMap;
+}
+
+function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot {
+ const childrenByParent = new Map();
+ const commandById = new Map();
for (const line of stdout.split(/\r?\n/g)) {
- const n = Number.parseInt(line.trim(), 10);
- if (Number.isInteger(n) && n > 0) {
- return n;
- }
+ // `comm=` is the final column and may itself contain spaces, so only the
+ // first two tokens are structural.
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
+ if (!match) continue;
+ const pid = Number(match[1]);
+ const ppid = Number(match[2]);
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
+ commandById.set(pid, (match[3] ?? "").trim());
+ const children = childrenByParent.get(ppid) ?? [];
+ children.push(pid);
+ childrenByParent.set(ppid, children);
}
- return null;
+ return { childrenByParent, commandById };
}
-function windowsInspectSubprocess(
- terminalPid: number,
- platform: NodeJS.Platform,
-): Effect.Effect<
- TerminalSubprocessInspectResult,
- TerminalSubprocessCheckError,
- ProcessRunner.ProcessRunner
-> {
- const command =
- 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
- return Effect.gen(function* () {
- const processRunner = yield* ProcessRunner.ProcessRunner;
- return yield* processRunner.run({
- // powershell.exe is a real executable — never spawn it through cmd.exe
- // shell mode, which would re-tokenize the `-Command` payload (pipes,
- // semicolons) before PowerShell ever sees it.
- command: "powershell.exe",
- args: ["-NoProfile", "-NonInteractive", "-Command", command],
- timeout: "1500 millis",
- maxOutputBytes: 32_768,
- outputMode: "truncate",
- timeoutBehavior: "timedOutResult",
- });
- }).pipe(
- Effect.map((result) => {
- if (result.code !== 0) {
- return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
- }
- const processNameById = new Map();
- const childrenByParent = new Map();
- for (const line of result.stdout.split(/\r?\n/g)) {
- const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3);
- const pid = Number(pidRaw);
- const parentPid = Number(parentPidRaw);
- if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
- processNameById.set(pid, nameRaw?.trim() ?? "");
- const children = childrenByParent.get(parentPid) ?? [];
- children.push(pid);
- childrenByParent.set(parentPid, children);
- }
- const directChildren = childrenByParent.get(terminalPid) ?? [];
- const childPid = directChildren[0];
- if (childPid === undefined) {
- return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
- }
- const processIds = new Set([terminalPid]);
- const pending = [terminalPid];
- while (pending.length > 0) {
- const parentPid = pending.pop();
- if (parentPid === undefined) continue;
- for (const pid of childrenByParent.get(parentPid) ?? []) {
- if (processIds.has(pid)) continue;
- processIds.add(pid);
- pending.push(pid);
- }
- }
- const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform);
- return {
- hasRunningSubprocess: true,
- childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
- processIds: [...processIds],
- } as const;
- }),
- Effect.mapError(
- (cause) =>
- new TerminalSubprocessCheckError({
- cause,
- terminalPid,
- command: "powershell",
- }),
- ),
- );
+function parseWindowsProcessTable(stdout: string): TerminalProcessTableSnapshot {
+ const childrenByParent = new Map();
+ const commandById = new Map();
+ for (const line of stdout.split(/\r?\n/g)) {
+ const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3);
+ const pid = Number(pidRaw);
+ const parentPid = Number(parentPidRaw);
+ if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
+ commandById.set(pid, nameRaw?.trim() ?? "");
+ const children = childrenByParent.get(parentPid) ?? [];
+ children.push(pid);
+ childrenByParent.set(parentPid, children);
+ }
+ return { childrenByParent, commandById };
}
-const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* (
+function deriveSubprocessInspectResult(
+ snapshot: TerminalProcessTableSnapshot,
terminalPid: number,
platform: NodeJS.Platform,
+): TerminalSubprocessInspectResult {
+ const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0];
+ if (childPid === undefined) {
+ return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
+ }
+ const processIds = new Set([terminalPid]);
+ const pending = [terminalPid];
+ while (pending.length > 0) {
+ const parentPid = pending.pop();
+ if (parentPid === undefined) continue;
+ for (const pid of snapshot.childrenByParent.get(parentPid) ?? []) {
+ if (processIds.has(pid)) continue;
+ processIds.add(pid);
+ pending.push(pid);
+ }
+ }
+ const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform);
+ return {
+ hasRunningSubprocess: true,
+ childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
+ processIds: [...processIds],
+ };
+}
+
+const POSIX_PS_ABSOLUTE_PATHS = ["/bin/ps", "/usr/bin/ps"] as const;
+
+// Resolve `ps` to an absolute path once at startup. Spawning by bare name
+// walks every PATH entry per spawn (one failed posix_spawn per directory
+// until the hit), which is measurable at a 1s poll cadence on long PATHs.
+const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ for (const candidate of POSIX_PS_ABSOLUTE_PATHS) {
+ const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false));
+ if (exists) return candidate;
+ }
+ return "ps";
+});
+
+const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot")(function* (
+ psCommand: string,
): Effect.fn.Return<
- TerminalSubprocessInspectResult,
+ TerminalProcessTableSnapshot,
TerminalSubprocessCheckError,
ProcessRunner.ProcessRunner
> {
const processRunner = yield* ProcessRunner.ProcessRunner;
- const runPgrep = processRunner
- .run({
- command: "pgrep",
- args: ["-P", String(terminalPid)],
- timeout: "1 second",
- maxOutputBytes: 32_768,
- outputMode: "truncate",
- timeoutBehavior: "timedOutResult",
- })
- .pipe(
- Effect.mapError(
- (cause) =>
- new TerminalSubprocessCheckError({
- cause,
- terminalPid,
- command: "pgrep",
- }),
- ),
- );
-
- const runPs = processRunner
+ const result = yield* processRunner
.run({
- command: "ps",
- args: ["-eo", "pid=,ppid="],
+ command: psCommand,
+ args: ["-eo", "pid=,ppid=,comm="],
timeout: "1 second",
- maxOutputBytes: 262_144,
+ maxOutputBytes: 524_288,
outputMode: "truncate",
timeoutBehavior: "timedOutResult",
})
@@ -737,120 +723,66 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
(cause) =>
new TerminalSubprocessCheckError({
cause,
- terminalPid,
command: "ps",
}),
),
);
-
- let childPid: number | null = null;
-
- const pgrepResult = yield* Effect.exit(runPgrep);
- if (pgrepResult._tag === "Success") {
- if (pgrepResult.value.code === 0) {
- childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);
- } else if (pgrepResult.value.code === 1) {
- return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
- }
- }
-
- if (childPid === null) {
- const psResult = yield* Effect.exit(runPs);
- if (psResult._tag === "Failure" || psResult.value.code !== 0) {
- return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
- }
- for (const line of psResult.value.stdout.split(/\r?\n/g)) {
- const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
- const pid = Number(pidRaw);
- const ppid = Number(ppidRaw);
- if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
- if (ppid === terminalPid) {
- childPid = pid;
- break;
- }
- }
- }
-
- if (childPid === null) {
- return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
- }
-
- const runComm = processRunner.run({
- command: "ps",
- args: ["-p", String(childPid), "-o", "comm="],
- timeout: "1 second",
- maxOutputBytes: 8_192,
- outputMode: "truncate",
- timeoutBehavior: "timedOutResult",
- });
-
- const commResult = yield* Effect.exit(runComm);
- let rawComm: string | null = null;
- if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) {
- rawComm = commResult.value.stdout.trim();
- }
-
- if (!rawComm || rawComm.length === 0) {
- const runArgs = processRunner.run({
+ if (result.code !== 0 || result.timedOut || result.stdoutTruncated) {
+ // Not authoritative: an empty or partial table would mark every terminal
+ // idle and clear its registered process ids. Failing skips the tick.
+ return yield* new TerminalSubprocessCheckError({
command: "ps",
- args: ["-p", String(childPid), "-o", "args="],
- timeout: "1 second",
- maxOutputBytes: 16_384,
- outputMode: "truncate",
- timeoutBehavior: "timedOutResult",
+ exitCode: result.code,
+ timedOut: result.timedOut,
+ stdoutTruncated: result.stdoutTruncated,
});
- const argsResult = yield* Effect.exit(runArgs);
- if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) {
- const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? "";
- rawComm = first.length > 0 ? first : null;
- }
}
-
- const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null;
- const processIds = new Set([terminalPid]);
- const psResult = yield* Effect.exit(runPs);
- if (psResult._tag === "Success" && psResult.value.code === 0) {
- const childrenByParent = new Map();
- for (const line of psResult.value.stdout.split(/\r?\n/g)) {
- const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
- const pid = Number(pidRaw);
- const ppid = Number(ppidRaw);
- if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
- const children = childrenByParent.get(ppid) ?? [];
- children.push(pid);
- childrenByParent.set(ppid, children);
- }
- const pending = [terminalPid];
- while (pending.length > 0) {
- const parentPid = pending.pop();
- if (parentPid === undefined) continue;
- for (const child of childrenByParent.get(parentPid) ?? []) {
- if (processIds.has(child)) continue;
- processIds.add(child);
- pending.push(child);
- }
- }
- } else {
- processIds.add(childPid);
- }
- return {
- hasRunningSubprocess: true,
- childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
- processIds: [...processIds],
- };
+ return parsePosixProcessTable(result.stdout);
});
-function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) {
- return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) {
- if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
- return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
- }
- if (platform === "win32") {
- return yield* windowsInspectSubprocess(terminalPid, platform);
+const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")(
+ function* (): Effect.fn.Return<
+ TerminalProcessTableSnapshot,
+ TerminalSubprocessCheckError,
+ ProcessRunner.ProcessRunner
+ > {
+ const command =
+ 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
+ const processRunner = yield* ProcessRunner.ProcessRunner;
+ const result = yield* processRunner
+ .run({
+ // powershell.exe is a real executable — never spawn it through cmd.exe
+ // shell mode, which would re-tokenize the `-Command` payload (pipes,
+ // semicolons) before PowerShell ever sees it.
+ command: "powershell.exe",
+ args: ["-NoProfile", "-NonInteractive", "-Command", command],
+ timeout: "1500 millis",
+ maxOutputBytes: 262_144,
+ outputMode: "truncate",
+ timeoutBehavior: "timedOutResult",
+ })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new TerminalSubprocessCheckError({
+ cause,
+ command: "powershell",
+ }),
+ ),
+ );
+ if (result.code !== 0 || result.timedOut || result.stdoutTruncated) {
+ // Not authoritative: an empty or partial table would mark every terminal
+ // idle and clear its registered process ids. Failing skips the tick.
+ return yield* new TerminalSubprocessCheckError({
+ command: "powershell",
+ exitCode: result.code,
+ timedOut: result.timedOut,
+ stdoutTruncated: result.stdoutTruncated,
+ });
}
- return yield* posixInspectSubprocess(terminalPid, platform);
- });
-}
+ return parseWindowsProcessTable(result.stdout);
+ },
+);
function capHistory(history: string, maxLines: number): string {
if (history.length === 0) return history;
@@ -1227,12 +1159,27 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
const baseEnv = options.env ?? process.env;
const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
const processRunner = yield* ProcessRunner.ProcessRunner;
- const subprocessInspector =
- options.subprocessInspector ??
- ((terminalPid) =>
- defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe(
- Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
- ));
+ // One process-table snapshot per poll tick, shared across every terminal.
+ // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and
+ // can exhaust the PID space on hosts with many sessions (#6332).
+ const fetchProcessTableSnapshot = (
+ platform === "win32"
+ ? windowsProcessTableSnapshot()
+ : posixProcessTableSnapshot(yield* resolvePosixPsCommand())
+ ).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner));
+ const customSubprocessInspector = options.subprocessInspector;
+ const acquireSubprocessInspector: Effect.Effect<
+ TerminalSubprocessInspector,
+ TerminalSubprocessCheckError
+ > =
+ customSubprocessInspector !== undefined
+ ? Effect.succeed(customSubprocessInspector)
+ : Effect.map(
+ fetchProcessTableSnapshot,
+ (snapshot): TerminalSubprocessInspector =>
+ (terminalPid) =>
+ Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)),
+ );
const subprocessPollIntervalMs =
options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
@@ -2064,6 +2011,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
return;
}
+ const inspectorOption = yield* acquireSubprocessInspector.pipe(
+ Effect.map(Option.some),
+ Effect.catch((reason) =>
+ Effect.logWarning("failed to snapshot processes for terminal subprocess polling", {
+ reason,
+ }).pipe(Effect.as(Option.none())),
+ ),
+ );
+
+ if (Option.isNone(inspectorOption)) {
+ return;
+ }
+
+ const subprocessInspector = inspectorOption.value;
+
const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* (
session: TerminalSessionState & { pid: number },
) {
From 1add47b322ab1dfb5010bb363613650176b88088 Mon Sep 17 00:00:00 2001
From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:22:57 +0530
Subject: [PATCH 3/5] fix(web): add copying terminal selection with ctrl+c in
the web app (#5638)
---
.../src/components/ThreadTerminalDrawer.tsx | 18 ++---
.../settings/SettingsFontPreviews.tsx | 1 -
apps/web/src/contextMenuFallback.test.ts | 36 +++++++++-
apps/web/src/contextMenuFallback.ts | 27 +++++++
apps/web/src/localApi.test.ts | 10 +++
apps/web/src/localApi.ts | 10 ++-
apps/web/src/terminal/ghostty/surface.test.ts | 5 +-
apps/web/src/terminal/ghostty/surface.ts | 72 +++++++++++++++++--
packages/contracts/src/ipc.ts | 1 +
9 files changed, 159 insertions(+), 21 deletions(-)
diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx
index c59f682c4..87f0ed4ae 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.tsx
+++ b/apps/web/src/components/ThreadTerminalDrawer.tsx
@@ -440,7 +440,6 @@ export function TerminalViewport({
onData: (data) => handleData(data),
onResize: (cols, rows) => void resizeTerminal(cols, rows),
onSelectionChange: () => handleSelectionChange(),
- onCopy: (text) => handleCopy(text),
beforeKey: (event) => handleBeforeKey(event),
onLinkActivate: (text, event) => handleLinkActivate(text, event),
};
@@ -668,17 +667,6 @@ export function TerminalViewport({
})();
}
- function handleCopy(text: string): void {
- void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => {
- const activeTerminal = terminalRef.current;
- if (!activeTerminal) return;
- writeSystemMessage(
- activeTerminal,
- error instanceof Error ? error.message : "Unable to copy terminal selection",
- );
- });
- }
-
function handleData(data: string): void {
void (async () => {
const result = await writeTerminal(data);
@@ -696,6 +684,12 @@ export function TerminalViewport({
return;
}
clearSelectionAction();
+ // A copy shortcut that clears the selection (Ctrl+C) must also close
+ // the context menu that appears with the selection, but a clear that
+ // never opened a menu must not dismiss an unrelated one.
+ if (selectionActionMenuOpenRef.current) {
+ void localApi?.contextMenu.close();
+ }
}
const handleMouseUp = (event: MouseEvent) => {
diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx
index 05ea2c9f0..a678c2ad5 100644
--- a/apps/web/src/components/settings/SettingsFontPreviews.tsx
+++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx
@@ -238,7 +238,6 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu
onData: echo,
onResize: noop,
onSelectionChange: noop,
- onCopy: (text) => void navigator.clipboard?.writeText(text).catch(noop),
// Tab keeps walking the settings page instead of feeding the echo loop.
beforeKey: (event) => event.key !== "Tab",
onLinkActivate: noop,
diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts
index 29596e72a..d36f1a1d1 100644
--- a/apps/web/src/contextMenuFallback.test.ts
+++ b/apps/web/src/contextMenuFallback.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
-import { showContextMenuFallback } from "./contextMenuFallback";
+import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback";
type FakeListener = (event: FakeDomEvent) => void;
@@ -236,3 +236,37 @@ describe("showContextMenuFallback", () => {
await expect(selectionPromise).resolves.toBe("rename:project-b");
});
});
+
+describe("dismissContextMenu", () => {
+ it("resolves an open menu with null", async () => {
+ const selectionPromise = showContextMenuFallback([
+ { id: "rename", label: "Rename" },
+ { id: "delete", label: "Delete" },
+ ]);
+ expect(findButton("Rename")).toBeTruthy();
+
+ dismissContextMenu();
+
+ await expect(selectionPromise).resolves.toBeNull();
+ expect(findButton("Rename")).toBeUndefined();
+ });
+
+ it("is a no-op when no menu is open", async () => {
+ dismissContextMenu();
+ expect(findButton("Rename")).toBeUndefined();
+ });
+
+ it("dismisses the prior menu when a new one opens", async () => {
+ const firstPromise = showContextMenuFallback([{ id: "first", label: "First" }]);
+ expect(findButton("First")).toBeTruthy();
+
+ const secondPromise = showContextMenuFallback([{ id: "second", label: "Second" }]);
+
+ await expect(firstPromise).resolves.toBeNull();
+ expect(findButton("First")).toBeUndefined();
+ expect(findButton("Second")).toBeTruthy();
+
+ dismissContextMenu();
+ await expect(secondPromise).resolves.toBeNull();
+ });
+});
diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts
index 50f4340e2..769826e39 100644
--- a/apps/web/src/contextMenuFallback.ts
+++ b/apps/web/src/contextMenuFallback.ts
@@ -101,6 +101,21 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H
return false;
}
+// Only one fallback menu exists at a time in the renderer; the active one is
+// tracked so a state change (for example a terminal selection clearing) can
+// dismiss it with the same result as an outside click or Escape.
+let activeContextMenuDismiss: (() => void) | null = null;
+
+/**
+ * Closes the currently open fallback context menu, resolving its show() with
+ * null (the same result as dismissing by outside click or Escape). No-op when
+ * no fallback menu is open.
+ */
+export function dismissContextMenu(): void {
+ activeContextMenuDismiss?.();
+ activeContextMenuDismiss = null;
+}
+
/**
* Imperative DOM-based context menu for non-Electron environments.
* Supports nested submenus and resolves with the clicked leaf item id.
@@ -114,11 +129,16 @@ export function showContextMenuFallback(
let isDisposed = false;
let canDismissFromPointer = false;
+ const dismiss = () => cleanup(null);
+
const cleanup = (result: T | null) => {
if (isDisposed) {
return;
}
isDisposed = true;
+ if (activeContextMenuDismiss === dismiss) {
+ activeContextMenuDismiss = null;
+ }
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("contextmenu", onContextMenu, true);
@@ -299,6 +319,13 @@ export function showContextMenuFallback(
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("contextmenu", onContextMenu, true);
openMenu(items, position?.x ?? 0, position?.y ?? 0, 0);
+ // Only one fallback menu can be open at a time: a new show must dismiss
+ // any prior one, or its DOM and listeners leak and close() can only ever
+ // reach the newest menu.
+ if (activeContextMenuDismiss) {
+ activeContextMenuDismiss();
+ }
+ activeContextMenuDismiss = dismiss;
requestAnimationFrame(() => {
canDismissFromPointer = true;
diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts
index 064b92703..9220252cb 100644
--- a/apps/web/src/localApi.test.ts
+++ b/apps/web/src/localApi.test.ts
@@ -13,12 +13,14 @@ const showContextMenuFallbackMock =
position?: { x: number; y: number },
) => Promise
>();
+const dismissContextMenuMock = vi.fn<() => void>();
const requestConfirmDialogMock =
vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise | undefined>();
vi.mock("./contextMenuFallback", () => ({
showContextMenuFallback: showContextMenuFallbackMock,
+ dismissContextMenu: dismissContextMenuMock,
}));
vi.mock("./confirmDialog", () => ({
@@ -85,6 +87,14 @@ describe("LocalApi", () => {
expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
});
+ it("dismisses an open browser context menu without a desktop bridge", async () => {
+ const { createLocalApi } = await import("./localApi");
+
+ await createLocalApi().contextMenu.close();
+
+ expect(dismissContextMenuMock).toHaveBeenCalledOnce();
+ });
+
it("uses the themed confirmation host when it is available", async () => {
requestConfirmDialogMock.mockResolvedValue(true);
const { createLocalApi } = await import("./localApi");
diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts
index 5c8f4ec9d..863388106 100644
--- a/apps/web/src/localApi.ts
+++ b/apps/web/src/localApi.ts
@@ -1,7 +1,7 @@
import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts";
import { requestConfirmDialog } from "./confirmDialog";
-import { showContextMenuFallback } from "./contextMenuFallback";
+import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback";
import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage";
import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
@@ -41,6 +41,14 @@ function createBrowserLocalApi(): LocalApi {
}
return showContextMenuFallback(items, position);
},
+ // A native desktop menu blocks keyboard input and closes on outside
+ // interaction, so nothing to do there; the DOM fallback needs an explicit
+ // dismiss when the state behind it goes away.
+ close: async () => {
+ if (!window.desktopBridge) {
+ dismissContextMenu();
+ }
+ },
},
persistence: {
getClientSettings: async () => {
diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts
index 31bc47bdf..18cf95901 100644
--- a/apps/web/src/terminal/ghostty/surface.test.ts
+++ b/apps/web/src/terminal/ghostty/surface.test.ts
@@ -219,11 +219,12 @@ describe("isTerminalCopyShortcut", () => {
expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true);
});
- it("uses the conventional Ctrl+Shift+C shortcut elsewhere", () => {
- expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false);
+ it("copies with Ctrl+C and Ctrl+Shift+C elsewhere", () => {
+ expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(true);
expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe(
true,
);
+ expect(isTerminalCopyShortcut(event({}), "Linux x86_64")).toBe(false);
});
it("uses the produced character instead of the physical key position", () => {
diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts
index fc7a89c6d..8a9c796b9 100644
--- a/apps/web/src/terminal/ghostty/surface.ts
+++ b/apps/web/src/terminal/ghostty/surface.ts
@@ -333,7 +333,7 @@ export function isTerminalCopyShortcut(
platform = navigator.platform,
) {
if (event.key.toLowerCase() !== "c") return false;
- return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;
+ return isMacPlatform(platform) ? event.metaKey : event.ctrlKey;
}
export function isTerminalPasteShortcut(
@@ -463,7 +463,6 @@ export interface GhosttyTerminalSurfaceOptions {
readonly onData: (data: string) => void;
readonly onResize: (cols: number, rows: number) => void;
readonly onSelectionChange: () => void;
- readonly onCopy: (text: string) => void;
readonly beforeKey: (event: KeyboardEvent) => boolean;
readonly onLinkActivate: (text: string, event: MouseEvent) => void;
}
@@ -531,6 +530,8 @@ export class GhosttyTerminalSurface {
private theme: GhosttyTheme;
private readonly suppressedKeyCodes = new Set();
private pasteShortcutToken = 0;
+ private copyShortcutToken = 0;
+ private clearSelectionAfterCopy = false;
private wheelRemainder = 0;
private dprMedia: MediaQueryList | null = null;
// Read live on every blink decision, and watched so that dropping the
@@ -901,9 +902,58 @@ export class GhosttyTerminalSurface {
return;
}
if (isTerminalCopyShortcut(event) && this.hasSelection()) {
- event.preventDefault();
+ // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in
+ // onCopyEvent; not preventing the default keeps that path alive. WebKit
+ // omits the keyboard copy event without a DOM selection, so race the
+ // clipboard write against it the same way paste races its read. The
+ // Shift variant has no native event (Chrome binds Ctrl+Shift+C to
+ // inspect), so synthesize one with execCommand("copy").
+ if (event.shiftKey) {
+ event.preventDefault();
+ document.execCommand("copy");
+ } else {
+ // A plain Ctrl+C is also SIGINT on non-mac: clear the selection once
+ // it copies so the next Ctrl+C reaches the shell. The Shift chord and
+ // Cmd+C are copy-only, so they keep the selection; resetting the flag
+ // up front also drops any clear owed by an earlier gesture that never
+ // completed.
+ this.clearSelectionAfterCopy = !event.shiftKey && !isMacPlatform(navigator.platform);
+ const clipboard = navigator.clipboard;
+ if (typeof clipboard?.writeText === "function") {
+ // Defer the write past the default action: the native copy event
+ // (dispatched synchronously with the default action) claims the
+ // token first when it fires, and the write covers browsers whose
+ // shortcut produces no copy event. Skipping a write the native
+ // event already handled stops a stale resolution from clobbering a
+ // clipboard the user filled after this copy.
+ const token = ++this.copyShortcutToken;
+ const selection = this.getSelection();
+ void Promise.resolve().then(() => {
+ if (this.disposed || this.copyShortcutToken !== token) return;
+ void clipboard.writeText(selection).then(
+ () => {
+ // The write may have been superseded while in flight; only
+ // touch the selection if this gesture still owns the token.
+ if (this.disposed || this.copyShortcutToken !== token) return;
+ if (this.clearSelectionAfterCopy) {
+ this.clearSelectionAfterCopy = false;
+ this.clearSelection();
+ }
+ },
+ () => {
+ // The write failed and the native event has already had its
+ // chance, so nothing copied and no clear is owed by this
+ // gesture; a newer one may have just set the flag, so only
+ // drop it if this gesture still owns the token.
+ if (this.copyShortcutToken === token) {
+ this.clearSelectionAfterCopy = false;
+ }
+ },
+ );
+ });
+ }
+ }
this.suppressedKeyCodes.add(event.code);
- this.options.onCopy(this.getSelection());
return;
}
if (isTerminalPasteShortcut(event)) {
@@ -989,6 +1039,18 @@ export class GhosttyTerminalSurface {
this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange);
}
+ private readonly onCopyEvent = (event: ClipboardEvent) => {
+ if (!this.hasSelection()) return;
+ event.preventDefault();
+ event.clipboardData?.setData("text/plain", this.getSelection());
+ // The native event beat any deferred write; drop the in-flight fallback.
+ this.copyShortcutToken += 1;
+ if (this.clearSelectionAfterCopy) {
+ this.clearSelectionAfterCopy = false;
+ this.clearSelection();
+ }
+ };
+
private readonly onPaste = (event: ClipboardEvent) => {
// Always suppress the browser's default insertion: content the textarea
// would receive (for example an html-only clipboard converted to text)
@@ -1384,6 +1446,7 @@ export class GhosttyTerminalSurface {
this.input.addEventListener("blur", this.onBlur);
this.input.addEventListener("input", this.onInput);
this.input.addEventListener("paste", this.onPaste);
+ this.input.addEventListener("copy", this.onCopyEvent);
this.input.addEventListener("compositionstart", this.onCompositionStart);
this.input.addEventListener("compositionend", this.onCompositionEnd);
this.canvas.addEventListener("pointerdown", this.onPointerDown);
@@ -1408,6 +1471,7 @@ export class GhosttyTerminalSurface {
this.input.removeEventListener("blur", this.onBlur);
this.input.removeEventListener("input", this.onInput);
this.input.removeEventListener("paste", this.onPaste);
+ this.input.removeEventListener("copy", this.onCopyEvent);
this.input.removeEventListener("compositionstart", this.onCompositionStart);
this.input.removeEventListener("compositionend", this.onCompositionEnd);
this.canvas.removeEventListener("pointerdown", this.onPointerDown);
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index 4e4d4baa1..f99d4d34b 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -1189,6 +1189,7 @@ export interface LocalApi {
items: readonly ContextMenuItem[],
position?: { x: number; y: number },
) => Promise;
+ close: () => Promise;
};
persistence: {
getClientSettings: () => Promise;
From c9063f03ea1c16e0239e1996a9b6ef611679995d Mon Sep 17 00:00:00 2001
From: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Date: Fri, 14 Aug 2026 21:12:39 +0200
Subject: [PATCH 4/5] perf(desktop): speed up Windows update installation
(#6169)
---
.../src/app/DesktopEnvironment.test.ts | 19 +
apps/desktop/src/app/DesktopEnvironment.ts | 14 +-
.../DesktopBackendConfiguration.test.ts | 104 ++-
.../backend/DesktopBackendConfiguration.ts | 52 +-
apps/desktop/src/main.ts | 2 +
.../src/wsl/DesktopWslServerTree.test.ts | 323 ++++++++
apps/desktop/src/wsl/DesktopWslServerTree.ts | 226 ++++++
apps/server/package.json | 1 +
docs/operations/release.md | 31 +
patches/@ff-labs__fff-node@0.9.4.patch | 10 +-
pnpm-lock.yaml | 14 +-
scripts/build-desktop-artifact.test.ts | 407 +++++++++-
scripts/build-desktop-artifact.ts | 707 +++++++++++++++---
scripts/lib/cli-external-packages.test.ts | 53 +-
scripts/lib/cli-external-packages.ts | 46 +-
scripts/package.json | 1 +
16 files changed, 1822 insertions(+), 188 deletions(-)
create mode 100644 apps/desktop/src/wsl/DesktopWslServerTree.test.ts
create mode 100644 apps/desktop/src/wsl/DesktopWslServerTree.ts
diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts
index 15d23f8e1..218e2c3e4 100644
--- a/apps/desktop/src/app/DesktopEnvironment.test.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.test.ts
@@ -65,6 +65,7 @@ describe("DesktopEnvironment", () => {
assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
assert.equal(environment.rootDir, "/repo");
assert.equal(environment.appRoot, "/repo");
+ assert.equal(environment.serverRoot, "/repo");
assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
assert.equal(environment.backendCwd, "/repo");
assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev");
@@ -98,6 +99,24 @@ describe("DesktopEnvironment", () => {
}),
);
+ it.effect("uses the packaged Windows server sidecar as the backend root", () =>
+ Effect.gen(function* () {
+ const environment = yield* makeEnvironment({
+ platform: "win32",
+ isPackaged: true,
+ appPath: "/install/resources/app.asar",
+ resourcesPath: "/install/resources",
+ });
+
+ assert.equal(environment.appRoot, "/install/resources/app.asar");
+ assert.equal(environment.serverRoot, "/install/resources/server.asar");
+ assert.equal(
+ environment.backendEntryPath,
+ "/install/resources/server.asar/apps/server/dist/bin.mjs",
+ );
+ }),
+ );
+
it.effect("keeps implicit development state separate from production state", () =>
Effect.gen(function* () {
const development = yield* makeEnvironment(
diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts
index 1806289a0..eaf390187 100644
--- a/apps/desktop/src/app/DesktopEnvironment.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.ts
@@ -52,6 +52,13 @@ export class DesktopEnvironment extends Context.Service<
readonly browserArtifactsDir: string;
readonly rootDir: string;
readonly appRoot: string;
+ // Root of the tree containing apps/server/dist and node_modules for the
+ // backend. Equals appRoot everywhere except packaged Windows, where the
+ // server tree ships as the resources/server.asar sidecar (see
+ // scripts/build-desktop-artifact.ts) that the asar-aware
+ // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend
+ // extracts on demand (see DesktopWslServerTree).
+ readonly serverRoot: string;
readonly backendEntryPath: string;
readonly backendCwd: string;
readonly preloadPath: string;
@@ -157,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* (
});
const rootDir = path.resolve(input.dirname, "../../..");
const appRoot = input.isPackaged ? input.appPath : rootDir;
+ const serverRoot =
+ input.isPackaged && input.platform === "win32"
+ ? path.join(input.resourcesPath, "server.asar")
+ : appRoot;
const branding = resolveDesktopAppBranding({
isDevelopment,
appVersion: input.appVersion,
@@ -198,7 +209,8 @@ const make = Effect.fn("desktop.environment.make")(function* (
browserArtifactsDir: path.join(stateDir, "browser-artifacts"),
rootDir,
appRoot,
- backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"),
+ serverRoot,
+ backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"),
backendCwd: input.isPackaged ? homeDirectory : appRoot,
preloadPath: path.join(input.dirname, "preload.cjs"),
appUpdateYmlPath: input.isPackaged
diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
index 309dbb21d..2bbde73ab 100644
--- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
@@ -17,6 +17,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopServerExposure from "./DesktopServerExposure.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
+import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
const PersistedServerObservabilitySettingsDocument = Schema.Struct({
observability: Schema.Struct({
@@ -115,6 +116,7 @@ const withHarness = (
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(makeEnvironmentLayer(baseDir)),
),
),
@@ -153,6 +155,47 @@ describe("DesktopBackendConfiguration", () => {
),
);
+ it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-desktop-backend-config-test-",
+ });
+ const resourcesPath = `${baseDir}/resources`;
+
+ const config = yield* Effect.gen(function* () {
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ return yield* configuration.resolvePrimary;
+ }).pipe(
+ Effect.provide(
+ DesktopBackendConfiguration.layer.pipe(
+ Layer.provideMerge(serverExposureLayer),
+ Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslEnvironment.layerTest()),
+ Layer.provideMerge(
+ Layer.succeed(
+ DesktopWslServerTree.DesktopWslServerTree,
+ DesktopWslServerTree.DesktopWslServerTree.of({
+ ensure: Effect.die("Windows primary must not extract the WSL server tree"),
+ }),
+ ),
+ ),
+ Layer.provideMerge(
+ makeEnvironmentLayer(baseDir, {
+ appPath: `${resourcesPath}/app.asar`,
+ platform: "win32",
+ resourcesPath,
+ }),
+ ),
+ ),
+ ),
+ );
+
+ assert.equal(config.entryPath, `${resourcesPath}/server.asar/apps/server/dist/bin.mjs`);
+ assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1");
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
it.effect("resolveWsl reuses the primary's bootstrap token", () =>
withHarness(
Effect.gen(function* () {
@@ -173,7 +216,7 @@ describe("DesktopBackendConfiguration", () => {
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-config-test-",
});
- const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs");
+ const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs");
yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });
yield* fileSystem.writeFileString(entryPath, "");
@@ -186,6 +229,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
@@ -234,7 +278,7 @@ describe("DesktopBackendConfiguration", () => {
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-config-test-",
});
- const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs");
+ const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs");
yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });
yield* fileSystem.writeFileString(entryPath, "");
@@ -250,6 +294,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
@@ -386,6 +431,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layerTest()),
Layer.provideMerge(makeEnvironmentLayer(baseDir)),
Layer.provideMerge(failingFileSystemLayer),
@@ -427,6 +473,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layerTest()),
Layer.provideMerge(
makeEnvironmentLayer(baseDir, {
@@ -486,6 +533,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
@@ -536,6 +584,7 @@ describe("DesktopBackendConfiguration", () => {
wslOnly: true,
}),
),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })),
Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
),
@@ -573,6 +622,7 @@ describe("DesktopBackendConfiguration", () => {
wslDistro: "Removed-Distro",
}),
),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
@@ -606,6 +656,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
@@ -640,6 +691,49 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
+ Layer.provideMerge(
+ DesktopWslEnvironment.layerTest({
+ isAvailable: true,
+ distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
+ }),
+ ),
+ Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
+ ),
+ ),
+ );
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("resolveWsl surfaces sidecar extraction failures through typed preflight", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-desktop-backend-config-test-",
+ });
+
+ yield* Effect.gen(function* () {
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" });
+ const failure = Option.getOrThrow(config.preflightFailure);
+
+ assert.isFalse(failure.fatal);
+ assert.equal(failure.retryLimit, 12);
+ assert.include(failure.reason, "could not be extracted");
+ }).pipe(
+ Effect.provide(
+ DesktopBackendConfiguration.layer.pipe(
+ Layer.provideMerge(serverExposureLayer),
+ Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(
+ DesktopWslServerTree.layerTest({
+ result: {
+ ok: false,
+ reason: "WSL server files could not be extracted",
+ fatal: false,
+ },
+ }),
+ ),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
@@ -672,6 +766,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
@@ -708,6 +803,7 @@ describe("DesktopBackendConfiguration", () => {
wslDistro: "Ubuntu",
}),
),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })),
Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
),
@@ -748,6 +844,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layerTest()),
Layer.provideMerge(
makeEnvironmentLayer(baseDir, {
@@ -793,6 +890,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layerTest()),
Layer.provideMerge(
makeEnvironmentLayer(baseDir, {
@@ -843,6 +941,7 @@ describe("DesktopBackendConfiguration", () => {
wslDistro: "Ubuntu",
}),
),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })),
Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
),
@@ -864,6 +963,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layer),
// isAvailable on win32 only touches the filesystem, never the spawner,
// so a die-stub is enough to satisfy the layer's deps.
diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts
index bfb9d6900..bcce731a5 100644
--- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts
+++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts
@@ -19,6 +19,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopServerExposure from "./DesktopServerExposure.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
+import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()(
"DesktopBackendObservabilitySettingsReadError",
@@ -424,10 +425,12 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
never,
| DesktopEnvironment.DesktopEnvironment
| DesktopWslEnvironment.DesktopWslEnvironment
+ | DesktopWslServerTree.DesktopWslServerTree
| FileSystem.FileSystem
> {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
+ const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
// Bind to 0.0.0.0 inside WSL so the backend is reachable both via
// WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1
@@ -464,31 +467,31 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
...buildObservabilityFragment(input.observabilitySettings),
};
- // In packaged builds environment.appRoot is .../resources/app.asar — an
- // archive FILE. The Windows primary reads its entry through
- // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain
- // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks
- // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts)
- // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is
- // already a real directory, so this is a no-op.
- const wslAppRoot = environment.isPackaged
- ? environment.path.join(environment.resourcesPath, "app.asar.unpacked")
- : environment.appRoot;
+ // In packaged builds the server tree ships inside resources/server.asar —
+ // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE
+ // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which
+ // can't read an asar, so materialize (or reuse) the extracted copy of the
+ // sidecar before preflighting. In dev the server tree is the real checkout
+ // directory and ensure returns it unchanged.
+ const serverTree = yield* wslServerTree.ensure;
+ const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot;
const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs");
- const preflight = yield* runWslPreflight({
- distro: input.distro,
- windowsEntryPath: wslEntryPath,
- windowsRepoRoot: wslAppRoot,
- // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
- // attached to the Windows artifact — see build-desktop-artifact.ts), so the
- // WSL backend never needs a compiler, node-gyp, or network on first launch.
- // Compiling from source is a dev-only convenience: a checkout has no shipped
- // prebuilt, and developers have the toolchain. In packaged builds we instead
- // surface a clear diagnostic if the prebuilt can't load (unsupported
- // arch/distro), rather than silently dropping into a fragile runtime build.
- allowBuild: !environment.isPackaged,
- });
+ const preflight = serverTree.ok
+ ? yield* runWslPreflight({
+ distro: input.distro,
+ windowsEntryPath: wslEntryPath,
+ windowsRepoRoot: wslAppRoot,
+ // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
+ // attached to the Windows artifact — see build-desktop-artifact.ts), so the
+ // WSL backend never needs a compiler, node-gyp, or network on first launch.
+ // Compiling from source is a dev-only convenience: a checkout has no shipped
+ // prebuilt, and developers have the toolchain. In packaged builds we instead
+ // surface a clear diagnostic if the prebuilt can't load (unsupported
+ // arch/distro), rather than silently dropping into a fragile runtime build.
+ allowBuild: !environment.isPackaged,
+ })
+ : ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const);
// Every operation after preflight uses the same concrete distro. In
// default-tracking mode this closes the race where the system default
@@ -610,6 +613,7 @@ export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
+ const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
const settings = yield* DesktopAppSettings.DesktopAppSettings;
const crypto = yield* Crypto.Crypto;
// SynchronizedRef (not a plain Ref) so the read-generate-write is atomic.
@@ -665,6 +669,7 @@ export const make = Effect.gen(function* () {
}).pipe(
Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
+ Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree),
Effect.provideService(FileSystem.FileSystem, fileSystem),
);
});
@@ -727,6 +732,7 @@ export const make = Effect.gen(function* () {
return yield* resolveWslStartConfig({ ...shared, ...input }).pipe(
Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
+ Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree),
Effect.provideService(FileSystem.FileSystem, fileSystem),
);
}).pipe(
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 0616184ec..14caeed8a 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -62,6 +62,7 @@ import * as PreviewManager from "./preview/Manager.ts";
import * as DesktopWindow from "./window/DesktopWindow.ts";
import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";
+import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts";
const desktopEnvironmentLayer = Layer.unwrap(
Effect.gen(function* () {
@@ -165,6 +166,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe(
Layer.provideMerge(DesktopAppIdentity.layer),
Layer.provideMerge(DesktopBackendConfiguration.layer),
Layer.provideMerge(DesktopWslEnvironment.layer),
+ Layer.provideMerge(DesktopWslServerTree.layer),
Layer.provideMerge(DesktopTelemetryPublisher.layer),
Layer.provideMerge(desktopWindowLayer),
);
diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts
new file mode 100644
index 000000000..8c1a5b020
--- /dev/null
+++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts
@@ -0,0 +1,323 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, describe, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as PlatformError from "effect/PlatformError";
+import * as Ref from "effect/Ref";
+import * as Scope from "effect/Scope";
+
+import * as DesktopConfig from "../app/DesktopConfig.ts";
+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
+import * as DesktopWslServerTree from "./DesktopWslServerTree.ts";
+
+// The service reads packaged Windows roots through the (asar-aware, in
+// Electron) fs, so a plain directory named server.asar exercises the full
+// extraction path under plain Node.
+
+const environmentLayer = (input: {
+ readonly baseDir: string;
+ readonly resourcesPath: string;
+ readonly appVersion?: string;
+ readonly isPackaged?: boolean;
+}) =>
+ DesktopEnvironment.layer({
+ dirname: "/repo/apps/desktop/src",
+ homeDirectory: input.baseDir,
+ platform: "win32",
+ processArch: "x64",
+ appVersion: input.appVersion ?? "1.2.3",
+ appPath: "/repo",
+ isPackaged: input.isPackaged ?? true,
+ resourcesPath: input.resourcesPath,
+ runningUnderArm64Translation: false,
+ }).pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ NodeServices.layer,
+ DesktopConfig.layerTest({
+ T3CODE_HOME: input.baseDir,
+ T3CODE_MODE: "desktop",
+ }),
+ ),
+ ),
+ );
+
+const withTempDir = (
+ run: (tempDir: string) => Effect.Effect,
+): Effect.Effect<
+ A,
+ E | PlatformError.PlatformError,
+ FileSystem.FileSystem | Exclude
+> =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const tempDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-wsl-server-tree-test-",
+ });
+ return yield* run(tempDir);
+ }).pipe(Effect.scoped);
+
+const ensureWith = (input: {
+ readonly baseDir: string;
+ readonly resourcesPath: string;
+ readonly appVersion?: string;
+ readonly isPackaged?: boolean;
+}) =>
+ Effect.gen(function* () {
+ const tree = yield* DesktopWslServerTree.DesktopWslServerTree;
+ return yield* tree.ensure;
+ }).pipe(
+ Effect.provide(DesktopWslServerTree.layer.pipe(Layer.provideMerge(environmentLayer(input)))),
+ );
+
+describe("DesktopWslServerTree", () => {
+ it.effect("bounds entry work across an eight-way nested tree", () =>
+ Effect.gen(function* () {
+ const active = yield* Ref.make(0);
+ const maxActive = yield* Ref.make(0);
+ const visited = yield* Ref.make(0);
+
+ yield* DesktopWslServerTree.forEachBoundedTree([{ depth: 0, id: "root" }], (node) =>
+ Effect.acquireUseRelease(
+ Effect.gen(function* () {
+ const current = yield* Ref.updateAndGet(active, (count) => count + 1);
+ yield* Ref.update(maxActive, (maximum) => Math.max(maximum, current));
+ yield* Ref.update(visited, (count) => count + 1);
+ }),
+ () =>
+ Effect.gen(function* () {
+ // Give every task in the current batch a chance to overlap.
+ yield* Effect.yieldNow;
+ if (node.depth === 4) return [];
+ return Array.from({ length: 8 }, (_, index) => ({
+ depth: node.depth + 1,
+ id: `${node.id}.${String(index)}`,
+ }));
+ }),
+ () => Ref.update(active, (count) => count - 1),
+ ),
+ );
+
+ assert.equal(yield* Ref.get(active), 0);
+ assert.equal(yield* Ref.get(maxActive), 8);
+ assert.equal(yield* Ref.get(visited), 4_681);
+ }),
+ );
+
+ it.effect("returns the server root unchanged when it is a plain directory (dev)", () =>
+ withTempDir((tempDir) =>
+ Effect.gen(function* () {
+ const result = yield* ensureWith({
+ baseDir: tempDir,
+ resourcesPath: tempDir,
+ isPackaged: false,
+ });
+ assert.isTrue(result.ok);
+ assert.isFalse(result.ok && result.root.endsWith(".asar"));
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("extracts an archive root into a version-keyed state directory", () =>
+ withTempDir((tempDir) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const serverRoot = path.join(tempDir, "resources", "server.asar");
+ yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
+ recursive: true,
+ });
+ yield* fileSystem.writeFileString(
+ path.join(serverRoot, "apps/server/dist/bin.mjs"),
+ "server-entry",
+ );
+ yield* fileSystem.makeDirectory(path.join(serverRoot, "node_modules/effect"), {
+ recursive: true,
+ });
+ yield* fileSystem.writeFileString(
+ path.join(serverRoot, "node_modules/effect/package.json"),
+ "{}",
+ );
+
+ const result = yield* ensureWith({
+ baseDir: tempDir,
+ resourcesPath: path.join(tempDir, "resources"),
+ });
+
+ assert.isTrue(result.ok);
+ const root = result.ok ? result.root : "";
+ assert.include(root, path.join("wsl-server-tree", "1.2.3"));
+ const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs"));
+ assert.equal(entry, "server-entry");
+ const dep = yield* fileSystem.exists(path.join(root, "node_modules/effect/package.json"));
+ assert.isTrue(dep);
+ const marker = yield* fileSystem.readFileString(
+ path.join(root, "t3code-wsl-server-tree.json"),
+ );
+ assert.include(marker, '"version":"1.2.3"');
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("serializes concurrent extraction callers and publishes one complete tree", () =>
+ withTempDir((tempDir) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const resourcesPath = path.join(tempDir, "resources");
+ const serverRoot = path.join(resourcesPath, "server.asar");
+ yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
+ recursive: true,
+ });
+ yield* fileSystem.writeFileString(
+ path.join(serverRoot, "apps/server/dist/bin.mjs"),
+ "server-entry",
+ );
+
+ const results = yield* Effect.gen(function* () {
+ const tree = yield* DesktopWslServerTree.DesktopWslServerTree;
+ return yield* Effect.all([tree.ensure, tree.ensure], { concurrency: "unbounded" });
+ }).pipe(
+ Effect.provide(
+ DesktopWslServerTree.layer.pipe(
+ Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })),
+ ),
+ ),
+ );
+
+ assert.isTrue(results.every((result) => result.ok));
+ const roots = results.flatMap((result) => (result.ok ? [result.root] : []));
+ assert.lengthOf(new Set(roots), 1);
+ assert.equal(
+ yield* fileSystem.readFileString(path.join(roots[0] ?? "", "apps/server/dist/bin.mjs")),
+ "server-entry",
+ );
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("reuses a completed extraction instead of copying again", () =>
+ withTempDir((tempDir) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const serverRoot = path.join(tempDir, "resources", "server.asar");
+ yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
+ recursive: true,
+ });
+ yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "v1");
+
+ const first = yield* ensureWith({
+ baseDir: tempDir,
+ resourcesPath: path.join(tempDir, "resources"),
+ });
+ assert.isTrue(first.ok);
+
+ // Mutate the source; a reused tree must keep the first copy.
+ yield* fileSystem.writeFileString(
+ path.join(serverRoot, "apps/server/dist/bin.mjs"),
+ "v2-should-not-appear",
+ );
+ const second = yield* ensureWith({
+ baseDir: tempDir,
+ resourcesPath: path.join(tempDir, "resources"),
+ });
+ assert.isTrue(second.ok);
+ const root = second.ok ? second.root : "";
+ const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs"));
+ assert.equal(entry, "v1");
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("sweeps stale version directories and leftover partials after extraction", () =>
+ withTempDir((tempDir) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const serverRoot = path.join(tempDir, "resources", "server.asar");
+ yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
+ recursive: true,
+ });
+ yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "x");
+
+ // T3CODE_HOME is set to tempDir, so the desktop state dir resolves to
+ // /userdata (no .t3 segment).
+ const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree");
+ yield* fileSystem.makeDirectory(path.join(treeRoot, "1.0.0"), { recursive: true });
+ yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3.partial"), { recursive: true });
+
+ const result = yield* ensureWith({
+ baseDir: tempDir,
+ resourcesPath: path.join(tempDir, "resources"),
+ });
+ assert.isTrue(result.ok);
+ assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.0.0")));
+ assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3.partial")));
+ assert.isTrue(yield* fileSystem.exists(path.join(treeRoot, "1.2.3")));
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("re-extracts when the app version changes", () =>
+ withTempDir((tempDir) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const serverRoot = path.join(tempDir, "resources", "server.asar");
+ yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), {
+ recursive: true,
+ });
+ yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "old");
+
+ const first = yield* ensureWith({
+ baseDir: tempDir,
+ resourcesPath: path.join(tempDir, "resources"),
+ appVersion: "1.2.3",
+ });
+ assert.isTrue(first.ok);
+
+ yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "new");
+ const second = yield* ensureWith({
+ baseDir: tempDir,
+ resourcesPath: path.join(tempDir, "resources"),
+ appVersion: "1.2.4",
+ });
+ assert.isTrue(second.ok);
+ const root = second.ok ? second.root : "";
+ assert.include(root, "1.2.4");
+ const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs"));
+ assert.equal(entry, "new");
+ // The previous version's tree is gone.
+ const treeRoot = path.dirname(root);
+ assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3")));
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("reports a retryable failure when the archive cannot be read", () =>
+ withTempDir((tempDir) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const result = yield* ensureWith({
+ baseDir: tempDir,
+ // resources dir exists but server.asar does not
+ resourcesPath: path.join(tempDir, "resources"),
+ });
+ assert.isFalse(result.ok);
+ if (!result.ok) {
+ assert.include(result.reason, "could not be extracted");
+ assert.isFalse(result.fatal);
+ }
+ const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree");
+ const leftovers = yield* fileSystem
+ .readDirectory(treeRoot)
+ .pipe(Effect.orElseSucceed(() => []));
+ assert.deepStrictEqual(leftovers, []);
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+});
diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.ts b/apps/desktop/src/wsl/DesktopWslServerTree.ts
new file mode 100644
index 000000000..0b87f7bf1
--- /dev/null
+++ b/apps/desktop/src/wsl/DesktopWslServerTree.ts
@@ -0,0 +1,226 @@
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
+
+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
+
+// Packaged Windows builds ship the server tree inside resources/server.asar
+// (see scripts/build-desktop-artifact.ts). The Windows primary reads it in
+// place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL
+// backend launches plain `wsl.exe -- node`, which cannot read an asar
+// archive. This service materializes the archive into a real, version-keyed
+// directory the first time the WSL backend starts, and reuses it afterwards —
+// so only users who enable WSL ever pay for a loose copy of the server tree.
+//
+// Reading through Electron's patched fs also transparently returns the
+// contents of files that electron-builder/asar left in the server.asar.unpacked
+// sibling (native binaries), so a single walk of the archive yields the
+// complete tree.
+
+export type WslServerTreeResult =
+ | { readonly ok: true; readonly root: string }
+ | { readonly ok: false; readonly reason: string; readonly fatal: boolean };
+
+const MARKER_FILE_NAME = "t3code-wsl-server-tree.json";
+const COPY_CONCURRENCY = 8;
+
+const Marker = Schema.Struct({ version: Schema.String });
+const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker));
+const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker));
+
+export class DesktopWslServerTreeExtractError extends Schema.TaggedErrorClass()(
+ "DesktopWslServerTreeExtractError",
+ {
+ targetDir: Schema.String,
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Failed to extract the WSL server tree to ${this.targetDir}.`;
+ }
+}
+
+export class DesktopWslServerTree extends Context.Service<
+ DesktopWslServerTree,
+ {
+ // Resolves the directory the WSL backend should treat as the app root
+ // (the directory containing apps/server/dist and node_modules). In dev
+ // the checkout already is that directory; packaged Windows builds extract
+ // server.asar on first use.
+ readonly ensure: Effect.Effect;
+ }
+>()("@t3tools/desktop/wsl/DesktopWslServerTree") {}
+
+// Child scheduling stays here instead of inside `visit`, so nested directories
+// cannot create independent concurrency pools. The LIFO work list also keeps
+// traversal memory proportional to the remaining frontier rather than the
+// number of active fibers.
+export const forEachBoundedTree = (
+ roots: ReadonlyArray,
+ visit: (node: Node) => Effect.Effect, E, R>,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const pending = [...roots];
+ while (pending.length > 0) {
+ const batch = pending.splice(-COPY_CONCURRENCY);
+ const children = yield* Effect.forEach(batch, visit, {
+ concurrency: COPY_CONCURRENCY,
+ });
+ for (const entries of children) {
+ pending.push(...entries);
+ }
+ }
+ });
+
+interface CopyTreeEntry {
+ readonly sourcePath: string;
+ readonly targetPath: string;
+}
+
+// Copy using only operations supported by Electron's asar-patched fs. Symlinks
+// are not expected because the sidecar is installed with a hoisted, physical
+// layout; anything that is neither a file nor a directory is skipped.
+const copyTree = (
+ fs: FileSystem.FileSystem,
+ join: (first: string, ...rest: string[]) => string,
+ from: string,
+ to: string,
+): Effect.Effect =>
+ forEachBoundedTree(
+ [{ sourcePath: from, targetPath: to }],
+ ({ sourcePath, targetPath }) =>
+ Effect.gen(function* () {
+ const info = yield* fs.stat(sourcePath);
+ if (info.type === "Directory") {
+ yield* fs.makeDirectory(targetPath, { recursive: true });
+ const entries = yield* fs.readDirectory(sourcePath);
+ return entries.map((entry) => ({
+ sourcePath: join(sourcePath, entry),
+ targetPath: join(targetPath, entry),
+ }));
+ }
+ if (info.type === "File") {
+ // Read and write stay in the same bounded task, so at most eight file
+ // buffers can be retained while their writes complete.
+ const bytes = yield* fs.readFile(sourcePath);
+ yield* fs.writeFile(targetPath, bytes);
+ }
+ return [];
+ }),
+ );
+
+export const make = Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const fs = yield* FileSystem.FileSystem;
+ const join = environment.path.join;
+
+ const serverRoot = environment.serverRoot;
+ const needsExtraction = environment.isPackaged && environment.platform === "win32";
+ const treeRoot = join(environment.stateDir, "wsl-server-tree");
+ const version = environment.appVersion;
+ const versionDir = join(treeRoot, version);
+
+ // Remove sibling trees left behind by previous app versions (and aborted
+ // extractions). Best-effort: a locked file must not block the backend.
+ const sweepStale = Effect.gen(function* () {
+ const entries = yield* fs.readDirectory(treeRoot).pipe(Effect.orElseSucceed(() => []));
+ yield* Effect.forEach(
+ entries.filter((entry) => entry !== version),
+ (entry) => fs.remove(join(treeRoot, entry), { recursive: true }).pipe(Effect.ignore),
+ { discard: true },
+ );
+ });
+
+ const markerMatches = Effect.gen(function* () {
+ const raw = yield* fs.readFileString(join(versionDir, MARKER_FILE_NAME));
+ const marker = yield* decodeMarker(raw);
+ return marker.version === version;
+ }).pipe(Effect.orElseSucceed(() => false));
+
+ const extract = Effect.gen(function* () {
+ yield* Effect.log(`[wsl-server-tree] Extracting ${serverRoot} to ${versionDir}...`);
+ yield* fs.makeDirectory(treeRoot, { recursive: true });
+ // Keep the temporary tree beside the target so rename is atomic. Cleanup
+ // is owned explicitly because a scoped temp-directory finalizer treats the
+ // successful rename (and therefore missing original path) as an error.
+ const partialDir = yield* fs.makeTempDirectory({
+ directory: treeRoot,
+ prefix: `.${version}.extract-`,
+ });
+ yield* Effect.gen(function* () {
+ yield* copyTree(fs, join, serverRoot, partialDir);
+ const markerJson = yield* encodeMarker({ version });
+ yield* fs.writeFileString(join(partialDir, MARKER_FILE_NAME), `${markerJson}\n`);
+ // The marker is written before the rename, so a directory named after
+ // the version is complete by construction.
+ yield* fs.remove(versionDir, { recursive: true }).pipe(Effect.ignore);
+ yield* fs.rename(partialDir, versionDir);
+ }).pipe(
+ Effect.ensuring(fs.remove(partialDir, { recursive: true, force: true }).pipe(Effect.ignore)),
+ );
+ yield* Effect.log(`[wsl-server-tree] Extraction complete at ${versionDir}.`);
+ }).pipe(
+ Effect.mapError(
+ (cause) => new DesktopWslServerTreeExtractError({ targetDir: versionDir, cause }),
+ ),
+ );
+
+ // Serialize concurrent ensure calls (backend restarts can overlap): the
+ // first caller extracts, later callers see the marker and reuse the tree.
+ const gate = yield* Semaphore.make(1);
+
+ const ensure: Effect.Effect = gate
+ .withPermits(1)(
+ Effect.gen(function* () {
+ if (!needsExtraction) {
+ return { ok: true, root: serverRoot } as const;
+ }
+ if (yield* markerMatches) {
+ yield* sweepStale;
+ return { ok: true, root: versionDir } as const;
+ }
+ const result = yield* extract.pipe(
+ Effect.map(() => ({ ok: true, root: versionDir }) as const),
+ // Retryable: transient antivirus locks and slow disks are the common
+ // causes, and the backend manager already bounds preflight retries.
+ Effect.catch((error) =>
+ Effect.succeed({
+ ok: false,
+ reason: `WSL server files could not be extracted to ${versionDir}: ${
+ error.cause instanceof Error ? error.cause.message : String(error.cause)
+ }`,
+ fatal: false,
+ } as const),
+ ),
+ );
+ if (result.ok) {
+ yield* sweepStale;
+ }
+ return result;
+ }),
+ )
+ .pipe(Effect.withSpan("desktop.wslServerTree.ensure"));
+
+ return DesktopWslServerTree.of({ ensure });
+});
+
+export const layer = Layer.effect(DesktopWslServerTree, make);
+
+export interface DesktopWslServerTreeTestStub {
+ readonly result?: WslServerTreeResult;
+}
+
+export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
+ Layer.effect(
+ DesktopWslServerTree,
+ Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ return DesktopWslServerTree.of({
+ ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }),
+ });
+ }),
+ );
diff --git a/apps/server/package.json b/apps/server/package.json
index 7a508a38e..eb4dc7dd3 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -31,6 +31,7 @@
"@opencode-ai/sdk": "^1.3.15",
"@pierre/diffs": "catalog:",
"effect": "catalog:",
+ "msgpackr-extract": "3.0.4",
"node-pty": "^1.1.0",
"yaml": "catalog:"
},
diff --git a/docs/operations/release.md b/docs/operations/release.md
index 1d8768f59..1cec84054 100644
--- a/docs/operations/release.md
+++ b/docs/operations/release.md
@@ -214,6 +214,37 @@ desktop-managed guidance when those environments are available.
- `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon.
- The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release.
+### Windows payload topology and update validation
+
+Windows packages the bundled server and only its runtime-external/native
+dependency closure in `resources/server.asar`. Native modules and helper
+executables declared as unpacked by that archive must be present at the matching
+paths below `resources/server.asar.unpacked`. The Windows-native backend reads
+the archive in place through Electron. WSL cannot read ASAR files, so enabling
+the WSL backend extracts the server tree once into the desktop state directory
+under `wsl-server-tree/` and reuses the completed version until the app
+is updated.
+
+The artifact builder rejects a Windows package when any of these invariants
+break:
+
+- `resources/server.asar` is absent or does not contain the server entry.
+- Any file marked unpacked in the ASAR header is absent from
+ `resources/server.asar.unpacked`.
+- On same-architecture Windows builds, the packaged primary cannot load the fff
+ native library from inside `server.asar` through its `.unpacked` sibling.
+- The isolated, extracted sidecar cannot load the server entry with plain Node.
+- The external Windows resource monitor is absent.
+- The unpacked Windows application contains more than 80 files.
+
+Cross-architecture Windows builds retain every structural and extracted-sidecar
+check, but skip executing the target Electron binary. A same-architecture build
+for each release target must exercise the primary native-load probe.
+
+NSIS differential packaging remains enabled. A sidecar layout transition can
+produce a larger one-time download; subsequent small releases retain their
+blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update.
+
## 0) npm OIDC trusted publishing setup (CLI)
The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That
diff --git a/patches/@ff-labs__fff-node@0.9.4.patch b/patches/@ff-labs__fff-node@0.9.4.patch
index 2d0c16133..74c132926 100644
--- a/patches/@ff-labs__fff-node@0.9.4.patch
+++ b/patches/@ff-labs__fff-node@0.9.4.patch
@@ -11,16 +11,18 @@ index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef1
import { fileURLToPath } from "node:url";
import { getLibFilename, getNpmPackageName } from "./platform.js";
/**
-@@ -46,6 +46,14 @@ function getPackageDir() {
+@@ -46,6 +46,16 @@ function getPackageDir() {
// Fallback: assume we're one level deep in src/
return dirname(currentDir);
}
+function resolveUnpackedAsarPath(binaryPath) {
-+ const asarSegment = `${sep}app.asar${sep}`;
-+ if (!binaryPath.includes(asarSegment)) {
++ const pathSegments = binaryPath.split(sep);
++ const asarIndex = pathSegments.findLastIndex((segment) => segment.endsWith(".asar"));
++ if (asarIndex === -1) {
+ return binaryPath;
+ }
-+ const unpackedPath = binaryPath.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
++ pathSegments[asarIndex] = `${pathSegments[asarIndex]}.unpacked`;
++ const unpackedPath = pathSegments.join(sep);
+ return existsSync(unpackedPath) ? unpackedPath : binaryPath;
+}
/**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7eab1715c..2c79aea36 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -75,7 +75,7 @@ patchedDependencies:
'@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1
'@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
'@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46
- '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8
+ '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
'@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5
'@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
'@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045
@@ -463,7 +463,7 @@ importers:
version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))
'@ff-labs/fff-node':
specifier: 0.9.4
- version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)
+ version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)
'@opencode-ai/sdk':
specifier: ^1.3.15
version: 1.15.13
@@ -473,6 +473,9 @@ importers:
effect:
specifier: 4.0.0-beta.103
version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
+ msgpackr-extract:
+ specifier: 3.0.4
+ version: 3.0.4
node-pty:
specifier: ^1.1.0
version: 1.1.0
@@ -913,6 +916,9 @@ importers:
'@effect/platform-node':
specifier: 4.0.0-beta.103
version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6)
+ '@electron/asar':
+ specifier: ^3.4.1
+ version: 3.4.1
'@t3tools/contracts':
specifier: workspace:*
version: link:../packages/contracts
@@ -12789,7 +12795,7 @@ snapshots:
'@ff-labs/fff-bin-win32-x64@0.9.4':
optional: true
- '@ff-labs/fff-node@0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)':
+ '@ff-labs/fff-node@0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)':
dependencies:
ffi-rs: 1.3.2
optionalDependencies:
@@ -19143,7 +19149,6 @@ snapshots:
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
- optional: true
msgpackr@2.0.4:
optionalDependencies:
@@ -19237,7 +19242,6 @@ snapshots:
node-gyp-build-optional-packages@5.2.2:
dependencies:
detect-libc: 2.1.2
- optional: true
node-gyp-build@4.8.4:
optional: true
diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts
index 6b04d6587..2b9fd3e02 100644
--- a/scripts/build-desktop-artifact.test.ts
+++ b/scripts/build-desktop-artifact.test.ts
@@ -2,15 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as FileSystem from "effect/FileSystem";
-import * as Path from "effect/Path";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+import * as Path from "effect/Path";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { ChildProcessSpawner } from "effect/unstable/process";
import {
+ BundleNotSelfContainedError,
BuildCommandFailedError,
createStageWorkspaceConfig,
createStagePatchedDependencies,
@@ -26,6 +27,7 @@ import {
LinuxIconResizeError,
MacPasskeySigningConfigurationResolutionError,
MissingMacPasskeyProvisioningProfileError,
+ packWindowsServerAsar,
renderMacPasskeyEntitlements,
resolveClerkPasskeyNativeArtifacts,
resolveMacPasskeySigningConfiguration,
@@ -44,9 +46,17 @@ import {
resolvePackageManagerUserAgent,
stageLinuxIconSize,
STAGE_INSTALL_ARGS,
- WINDOWS_ASAR_UNPACK,
ancestorNodeModulesPaths,
copyDirectoryPreservingSymlinks,
+ validateWindowsPackagedPayload,
+ WindowsPrimaryNativeProbeError,
+ WindowsPackagedPayloadValidationError,
+ WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT,
+ WINDOWS_SERVER_ASAR_IGNORE_GLOBS,
+ WINDOWS_SERVER_EXTRA_RESOURCES,
+ WINDOWS_SERVER_ASAR_RESOURCE,
+ WINDOWS_SERVER_ASAR_UNPACK_GLOB,
+ WINDOWS_SERVER_RESOURCE_SOURCE_DIR,
} from "./build-desktop-artifact.ts";
import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
@@ -88,6 +98,54 @@ function iconResizeSpawnerLayer(
);
}
+const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: {
+ readonly copyUnpackedNatives: boolean;
+ readonly serverEntrySource?: string;
+}) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const tempDir = yield* fs.makeTempDirectoryScoped({
+ prefix: "t3-windows-payload-test-",
+ });
+ const sourceDir = path.join(tempDir, "server-source");
+ const serverEntryPath = path.join(sourceDir, "apps/server/dist/bin.mjs");
+ const nativePath = path.join(sourceDir, "node_modules/native/addon.node");
+ yield* fs.makeDirectory(path.dirname(serverEntryPath), { recursive: true });
+ yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true });
+ yield* fs.writeFileString(serverEntryPath, input.serverEntrySource ?? "console.log('server');\n");
+ yield* fs.writeFileString(nativePath, "native-binary");
+
+ const generatedAsarPath = path.join(tempDir, WINDOWS_SERVER_ASAR_RESOURCE);
+ yield* packWindowsServerAsar({ sourceDir, asarPath: generatedAsarPath });
+
+ const stageDistDir = path.join(tempDir, "dist");
+ const packagedAppDir = path.join(stageDistDir, "win-unpacked");
+ const resourcesDir = path.join(packagedAppDir, "resources");
+ yield* fs.makeDirectory(path.join(resourcesDir, "resource-monitor"), { recursive: true });
+ yield* fs.copyFile(generatedAsarPath, path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE));
+ if (input.copyUnpackedNatives) {
+ yield* fs.copy(
+ `${generatedAsarPath}.unpacked`,
+ path.join(resourcesDir, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`),
+ );
+ }
+ yield* fs.writeFileString(
+ path.join(resourcesDir, "resource-monitor/t3-resource-monitor.exe"),
+ "monitor",
+ );
+ const appExecutableName = "t3code.exe";
+ yield* fs.writeFileString(path.join(packagedAppDir, appExecutableName), "electron");
+ yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad");
+
+ return {
+ stageDistDir,
+ packagedAppDir,
+ sourceDir,
+ generatedAsarPath,
+ appExecutableName,
+ } as const;
+});
+
it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
it("resolves the dedicated nightly updater channel from nightly versions", () => {
assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly");
@@ -232,22 +290,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
libc: ["glibc"],
},
});
- // Windows artifacts also bundle the same-architecture WSL (Linux, glibc) backend, so the
- // staged install must fetch its native optional deps (e.g. ffi-rs) too.
+ // The Windows app stage only serves the desktop main process; the server
+ // sidecar stage is the one that needs Linux natives (below).
assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), {
supportedArchitectures: {
- os: ["win32", "linux"],
+ os: ["win32"],
cpu: ["x64"],
- libc: ["glibc"],
},
});
- assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), {
- supportedArchitectures: {
- os: ["win32", "linux"],
- cpu: ["arm64"],
- libc: ["glibc"],
+ // The server sidecar stage bundles the same-architecture WSL (Linux,
+ // glibc) backend, so its install must fetch Linux native optional deps
+ // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar
+ // packing and runtime extraction without symlinks.
+ assert.deepStrictEqual(
+ createStageWorkspaceConfig({ platform: "win", arch: "x64", linuxServerBackend: true }),
+ {
+ supportedArchitectures: {
+ os: ["win32", "linux"],
+ cpu: ["x64"],
+ libc: ["glibc"],
+ },
+ nodeLinker: "hoisted",
},
- });
+ );
+ assert.deepStrictEqual(
+ createStageWorkspaceConfig({ platform: "win", arch: "arm64", linuxServerBackend: true }),
+ {
+ supportedArchitectures: {
+ os: ["win32", "linux"],
+ cpu: ["arm64"],
+ libc: ["glibc"],
+ },
+ nodeLinker: "hoisted",
+ },
+ );
assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {
supportedArchitectures: {
os: ["darwin"],
@@ -317,6 +393,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]);
assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [
"!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",
+ "!apps/desktop/prod-resources/windows-server",
+ "!apps/desktop/prod-resources/windows-server/**/*",
+ ]);
+ assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server");
+ assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [
+ {
+ from: "apps/desktop/prod-resources/windows-server",
+ to: ".",
+ filter: ["server.asar", "server.asar.unpacked/**/*"],
+ },
]);
});
@@ -350,9 +436,33 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
undefined,
);
+ // All platforms keep app.asar fully packed; Windows ships the server
+ // tree as the hand-packed server.asar sidecar in extraResources instead
+ // of unpacking thousands of loose files at install time.
assert.notProperty(mac, "asarUnpack");
assert.notProperty(linux, "asarUnpack");
- assert.deepStrictEqual(win.asarUnpack, WINDOWS_ASAR_UNPACK);
+ assert.notProperty(win, "asarUnpack");
+ assert.deepStrictEqual(win.extraResources, [
+ {
+ from: "apps/desktop/prod-resources/resource-monitor",
+ to: "resource-monitor",
+ },
+ ...WINDOWS_SERVER_EXTRA_RESOURCES,
+ ]);
+ assert.deepStrictEqual(win.nsis, { differentialPackage: true });
+ // Native binaries and helper executables cannot load from inside an
+ // asar; everything else stays packed. The Claude SDK platform packages
+ // and .bin shims never ship.
+ assert.equal(
+ WINDOWS_SERVER_ASAR_UNPACK_GLOB,
+ "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}",
+ );
+ assert.deepStrictEqual(WINDOWS_SERVER_ASAR_IGNORE_GLOBS, [
+ "**/node_modules/@anthropic-ai/claude-agent-sdk-*",
+ "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**",
+ "**/node_modules/.bin",
+ "**/node_modules/.bin/**",
+ ]);
// Linux must register the renderer schemes so the generated .desktop
// entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.
assert.deepStrictEqual((linux.linux as Record).protocols, [
@@ -365,6 +475,275 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
);
+ it.effect("validates every ASAR-unpacked native in the packaged Windows payload", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
+ const result = yield* validateWindowsPackagedPayload({
+ stageDistDir: fixture.stageDistDir,
+ appExecutableName: fixture.appExecutableName,
+ targetArch: "x64",
+ });
+
+ const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar");
+ yield* packWindowsServerAsar({
+ sourceDir: fixture.sourceDir,
+ asarPath: secondAsarPath,
+ });
+ const [firstAsar, secondAsar] = yield* Effect.all([
+ fs.readFile(fixture.generatedAsarPath),
+ fs.readFile(secondAsarPath),
+ ]);
+
+ assert.equal(result.packagedAppDir, fixture.packagedAppDir);
+ assert.deepStrictEqual(result.unpackedFiles, ["node_modules/native/addon.node"]);
+ assert.isBelow(result.fileCount, WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT);
+ assert.deepStrictEqual(secondAsar, firstAsar);
+ }),
+ ),
+ );
+
+ it.effect("probes fff through the packaged Windows primary instead of helper executables", () => {
+ const commands: Array<{
+ readonly command: string;
+ readonly args: ReadonlyArray;
+ readonly options: {
+ readonly cwd?: string;
+ readonly env?: Readonly>;
+ };
+ }> = [];
+ const spawnerLayer = Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make((command) => {
+ commands.push(command as unknown as (typeof commands)[number]);
+ return Effect.succeed(mockProcess(0));
+ }),
+ );
+
+ return Effect.scoped(
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
+ yield* validateWindowsPackagedPayload({
+ stageDistDir: fixture.stageDistDir,
+ appExecutableName: fixture.appExecutableName,
+ targetArch: "x64",
+ });
+
+ const primaryProbe = commands.find(
+ (command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1",
+ );
+ if (primaryProbe === undefined) return assert.fail("Windows primary probe was not spawned");
+
+ assert.equal(
+ primaryProbe.command,
+ path.join(fixture.packagedAppDir, fixture.appExecutableName),
+ );
+ assert.deepStrictEqual(primaryProbe.args.slice(0, 3), [
+ "--no-global-search-paths",
+ "--input-type=module",
+ "--eval",
+ ]);
+ assert.include(primaryProbe.args[3], "FileFinder.create");
+ assert.equal(
+ primaryProbe.args[4],
+ path.join(
+ fixture.packagedAppDir,
+ "resources/server.asar/node_modules/@ff-labs/fff-node/dist/src/index.js",
+ ),
+ );
+ assert.equal(primaryProbe.options.cwd, fixture.packagedAppDir);
+ assert.equal(primaryProbe.options.env?.NODE_PATH, "");
+ }),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ spawnerLayer,
+ Layer.succeed(HostProcessPlatform, "win32"),
+ Layer.succeed(HostProcessArchitecture, "x64"),
+ ),
+ ),
+ );
+ });
+
+ it.effect("skips the primary native probe for cross-architecture Windows payloads", () => {
+ const commands: Array<{
+ readonly command: string;
+ readonly options: {
+ readonly env?: Readonly>;
+ };
+ }> = [];
+ const spawnerLayer = Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make((command) => {
+ commands.push(command as unknown as (typeof commands)[number]);
+ return Effect.succeed(mockProcess(0));
+ }),
+ );
+
+ return Effect.scoped(
+ Effect.gen(function* () {
+ const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
+ yield* validateWindowsPackagedPayload({
+ stageDistDir: fixture.stageDistDir,
+ appExecutableName: fixture.appExecutableName,
+ targetArch: "arm64",
+ });
+
+ assert.isFalse(
+ commands.some((command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1"),
+ );
+ assert.isTrue(
+ commands.some(
+ (command) =>
+ command.command === process.execPath && command.options.env?.NODE_PATH === "",
+ ),
+ );
+ }),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ spawnerLayer,
+ Layer.succeed(HostProcessPlatform, "win32"),
+ Layer.succeed(HostProcessArchitecture, "x64"),
+ ),
+ ),
+ );
+ });
+
+ it.effect("rejects a cross-architecture Windows payload without its primary executable", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
+ const executablePath = path.join(fixture.packagedAppDir, fixture.appExecutableName);
+ yield* fs.remove(executablePath);
+
+ const error = yield* validateWindowsPackagedPayload({
+ stageDistDir: fixture.stageDistDir,
+ appExecutableName: fixture.appExecutableName,
+ targetArch: "arm64",
+ }).pipe(Effect.flip);
+
+ assert.instanceOf(error, WindowsPrimaryNativeProbeError);
+ assert.equal(error.executablePath, executablePath);
+ }),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ Layer.succeed(HostProcessPlatform, "win32"),
+ Layer.succeed(HostProcessArchitecture, "x64"),
+ ),
+ ),
+ ),
+ );
+
+ it.effect("rejects a packaged sidecar whose ASAR-unpacked native is missing", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: false });
+ const error = yield* validateWindowsPackagedPayload({
+ stageDistDir: fixture.stageDistDir,
+ appExecutableName: fixture.appExecutableName,
+ targetArch: "x64",
+ }).pipe(Effect.flip);
+
+ assert.instanceOf(error, WindowsPackagedPayloadValidationError);
+ assert.equal(error.reason, "unpacked-native-missing");
+ assert.deepStrictEqual(error.missingFiles, [
+ "server.asar.unpacked/node_modules/native/addon.node",
+ ]);
+ }),
+ ),
+ );
+
+ it.effect("rejects directories in place of packaged executable files", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
+ const nativePath = path.join(
+ fixture.packagedAppDir,
+ "resources/server.asar.unpacked/node_modules/native/addon.node",
+ );
+ yield* fs.remove(nativePath);
+ yield* fs.makeDirectory(nativePath);
+
+ const nativeError = yield* validateWindowsPackagedPayload({
+ stageDistDir: fixture.stageDistDir,
+ appExecutableName: fixture.appExecutableName,
+ targetArch: "x64",
+ }).pipe(Effect.flip);
+ assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError);
+ assert.equal(nativeError.reason, "unpacked-native-missing");
+ assert.deepStrictEqual(nativeError.missingFiles, [
+ "server.asar.unpacked/node_modules/native/addon.node",
+ ]);
+
+ yield* fs.remove(nativePath, { recursive: true });
+ yield* fs.writeFileString(nativePath, "native-binary");
+ const resourceMonitorPath = path.join(
+ fixture.packagedAppDir,
+ "resources/resource-monitor/t3-resource-monitor.exe",
+ );
+ yield* fs.remove(resourceMonitorPath);
+ yield* fs.makeDirectory(resourceMonitorPath);
+
+ const resourceMonitorError = yield* validateWindowsPackagedPayload({
+ stageDistDir: fixture.stageDistDir,
+ appExecutableName: fixture.appExecutableName,
+ targetArch: "x64",
+ }).pipe(Effect.flip);
+ assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError);
+ assert.equal(resourceMonitorError.reason, "resource-monitor-missing");
+ assert.deepStrictEqual(resourceMonitorError.missingFiles, [
+ "resource-monitor/t3-resource-monitor.exe",
+ ]);
+ }),
+ ),
+ );
+
+ it.effect("rejects a Windows payload that regresses above the file-count budget", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
+ const error = yield* validateWindowsPackagedPayload({
+ stageDistDir: fixture.stageDistDir,
+ appExecutableName: fixture.appExecutableName,
+ targetArch: "x64",
+ fileLimit: 2,
+ }).pipe(Effect.flip);
+
+ assert.instanceOf(error, WindowsPackagedPayloadValidationError);
+ assert.equal(error.reason, "file-limit-exceeded");
+ assert.isAbove(error.fileCount ?? 0, 2);
+ }),
+ ),
+ );
+
+ it.effect("rejects a sidecar whose extracted server bundle cannot resolve", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fixture = yield* makeWindowsPayloadFixture({
+ copyUnpackedNatives: true,
+ serverEntrySource: 'import "t3code-deliberately-missing-package";\n',
+ });
+ const error = yield* validateWindowsPackagedPayload({
+ stageDistDir: fixture.stageDistDir,
+ appExecutableName: fixture.appExecutableName,
+ targetArch: "x64",
+ }).pipe(Effect.flip);
+
+ assert.instanceOf(error, BundleNotSelfContainedError);
+ assert.include(error.output, "t3code-deliberately-missing-package");
+ }),
+ ),
+ );
+
it.effect("preserves both Linux icon resize failures with structural context", () => {
const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = [];
@@ -773,7 +1152,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
});
// The self-containment check runs the packaged tree in a scratch directory. Its
-// own node_modules holds the unpacked externals and must be ignored, but any
+// own node_modules holds the sidecar externals and must be ignored, but any
// node_modules *above* it would let Node's parent walk satisfy an import that is
// missing from the package, so the probe refuses to run in that case.
it("lists ancestor node_modules, nearest first, excluding the start directory", () => {
diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts
index c86f0c38c..0cda9766f 100644
--- a/scripts/build-desktop-artifact.ts
+++ b/scripts/build-desktop-artifact.ts
@@ -4,8 +4,16 @@
import * as NodeFSP from "node:fs/promises";
import * as NodeModule from "node:module";
+import {
+ createPackageWithOptions,
+ extractAll,
+ getRawHeader,
+ statFile,
+ type DirectoryRecord,
+} from "@electron/asar";
+
import { fromYaml } from "@t3tools/shared/schemaYaml";
-import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import rootPackageJson from "../package.json" with { type: "json" };
@@ -20,8 +28,8 @@ import {
} from "./lib/brand-assets.ts";
import { getDefaultBuildArch } from "./lib/build-target-arch.ts";
import {
- CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
findInlinedExternalPackages,
+ selectCliRuntimeExternalDependencies,
} from "./lib/cli-external-packages.ts";
import { loadRepoEnv } from "./lib/public-config.ts";
import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
@@ -69,6 +77,7 @@ const StageWorkspaceConfig = Schema.Struct({
allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+ nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),
});
type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type;
@@ -386,18 +395,36 @@ const desktopBuildInputArtifactNames = {
/**
* Imported by every server module, so it is inlined in any correctly bundled
* build. Its absence means the bundle went back to externalizing its
- * dependencies, which the unpack globs do not cover.
+ * dependencies, which the sidecar's selected runtime closure does not cover.
*/
const BUNDLE_SELF_CONTAINED_SENTINEL = "effect";
const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120);
+const WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT = Duration.seconds(30);
+
+const WINDOWS_PRIMARY_FFF_PROBE_SOURCE = `
+const { join } = await import("node:path");
+const { pathToFileURL } = await import("node:url");
+const { FileFinder } = await import(pathToFileURL(process.argv[1]).href);
+const probeRoot = process.argv[2];
+const result = FileFinder.create({
+ basePath: probeRoot,
+ frecencyDbPath: join(probeRoot, "frecency.mdb"),
+ historyDbPath: join(probeRoot, "history.mdb"),
+ disableWatch: true,
+ disableMmapCache: true,
+ disableContentIndexing: true,
+});
+if (!result.ok) throw new Error(result.error);
+result.value.destroy();
+`;
export class ExternalizedBundleError extends Schema.TaggedErrorClass()(
"ExternalizedBundleError",
{ sentinel: Schema.String, inlinedPackageCount: Schema.Number },
) {
override get message(): string {
- return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the native externals; if its dependencies are external again they will not be unpacked, and the WSL backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`;
+ return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the runtime externals; if its dependencies are external again they will be absent from the sidecar, and the backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`;
}
}
@@ -406,7 +433,7 @@ export class BundleNotSelfContainedError extends Schema.TaggedErrorClass()(
+ "WindowsServerSidecarPackError",
+ {
+ asarPath: Schema.String,
+ cause: Schema.optionalKey(Schema.Defect()),
+ },
+) {
+ override get message(): string {
+ return `Failed to pack the Windows server sidecar at ${this.asarPath}.`;
+ }
+}
+
+export class WindowsPrimaryNativeProbeError extends Schema.TaggedErrorClass()(
+ "WindowsPrimaryNativeProbeError",
+ {
+ executablePath: Schema.String,
+ exitCode: Schema.Number,
+ output: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `The packaged Windows primary could not load fff from server.asar (exit ${this.exitCode}). Output:\n${this.output}`;
+ }
+}
+
+const WindowsPackagedPayloadValidationReason = Schema.Literals([
+ "packaged-app-missing",
+ "sidecar-missing",
+ "sidecar-invalid",
+ "unpacked-native-missing",
+ "resource-monitor-missing",
+ "file-limit-exceeded",
+]);
+
+export class WindowsPackagedPayloadValidationError extends Schema.TaggedErrorClass()(
+ "WindowsPackagedPayloadValidationError",
+ {
+ reason: WindowsPackagedPayloadValidationReason,
+ packagedAppDir: Schema.String,
+ missingFiles: Schema.optionalKey(Schema.Array(Schema.String)),
+ fileCount: Schema.optionalKey(Schema.Int),
+ fileLimit: Schema.optionalKey(Schema.Int),
+ cause: Schema.optionalKey(Schema.Defect()),
+ },
+) {
+ override get message(): string {
+ if (this.reason === "file-limit-exceeded") {
+ return `Windows packaged payload contains ${String(this.fileCount)} files; expected at most ${String(this.fileLimit)}.`;
+ }
+ if (this.reason === "unpacked-native-missing") {
+ return `Windows server sidecar is missing ${String(this.missingFiles?.length ?? 0)} unpacked native files.`;
+ }
+ if (this.reason === "resource-monitor-missing") {
+ return "Windows packaged payload is missing the resource monitor executable.";
+ }
+ if (this.reason === "sidecar-invalid") {
+ return "Windows packaged payload contains an invalid server.asar sidecar.";
+ }
+ if (this.reason === "sidecar-missing") {
+ return "Windows packaged payload is missing resources/server.asar.";
+ }
+ return `Windows packaged application directory was not found at ${this.packagedAppDir}.`;
+ }
+}
+
export class WslNodePtyManifestReadError extends Schema.TaggedErrorClass()(
"WslNodePtyManifestReadError",
{
@@ -686,21 +778,47 @@ export const DESKTOP_FILE_EXCLUSIONS = [
// so the SDK's optional platform packages (each a ~200MB bundled executable)
// are dead weight. The trailing dash keeps the SDK's own JS package.
"!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",
+ // Windows stages the server sidecar below prod-resources so electron-builder
+ // can copy it using project-relative extraResources matchers. Keep those
+ // staging inputs out of app.asar; they are emitted once at resources/.
+ "!apps/desktop/prod-resources/windows-server",
+ "!apps/desktop/prod-resources/windows-server/**/*",
] as const;
-// The WSL backend launches the server with plain `wsl.exe -- node`, which cannot
-// read inside an asar archive, so everything it loads must be on the real
-// filesystem. This used to unpack `**\/node_modules\/**` wholesale, because the
-// server bundle externalized its runtime deps and the Linux Node would fail with
-// ERR_MODULE_NOT_FOUND ("Cannot find package 'effect'") before it even reached
-// node-pty.
-//
-// The CLI bundle now inlines its JS dependencies, so the only things that still
-// have to be loose are the server bundle itself and the packages the bundle
-// leaves external — derived from the same list the bundler uses, so the two
-// cannot drift apart.
-export const WINDOWS_ASAR_UNPACK = [
- "apps/server/dist/**",
- ...CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
+// Windows ships the server tree (bundle + node_modules) as a separate
+// resources/server.asar sidecar instead of loose files: the NSIS installer
+// then extracts a handful of large archives instead of thousands of small
+// files, which dominates install (and update) time. The Windows primary runs
+// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE
+// runtime; the WSL backend cannot read asar archives, so enabling WSL lazily
+// extracts the sidecar to a version-keyed directory (see DesktopWslServerTree).
+export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";
+// dlopen/spawn need real files, so native modules, shared libraries, and
+// helper executables live in the server.asar.unpacked sibling (the standard
+// asar redirect convention). Everything else stays packed.
+export const WINDOWS_SERVER_ASAR_UNPACK_GLOB =
+ "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}";
+// Mirrors DESKTOP_FILE_EXCLUSIONS for the hand-packed sidecar: the Claude SDK
+// platform packages are dead weight (see above), and node_modules/.bin shims
+// are never spawned at runtime (and are symlinks on POSIX build hosts, which
+// the asar extraction path deliberately does not support).
+export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [
+ "**/node_modules/@anthropic-ai/claude-agent-sdk-*",
+ "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**",
+ "**/node_modules/.bin",
+ "**/node_modules/.bin/**",
+] as const;
+export const WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT = 80;
+export const WINDOWS_SERVER_RESOURCE_SOURCE_DIR = "apps/desktop/prod-resources/windows-server";
+export const WINDOWS_SERVER_EXTRA_RESOURCES = [
+ {
+ // Copy the archive and its .unpacked sibling from one parent directory.
+ // Mapping the .unpacked directory as an independent FileSet silently
+ // omitted it from Windows packages even though electron-builder copied
+ // the adjacent archive.
+ from: WINDOWS_SERVER_RESOURCE_SOURCE_DIR,
+ to: ".",
+ filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`],
+ },
] as const;
export const DESKTOP_EXTRA_RESOURCES = [
{
@@ -1019,14 +1137,20 @@ export function createStageWorkspaceConfig(input: {
readonly allowBuilds?: Record;
readonly patchedDependencies?: Record;
readonly overrides?: Record;
+ // The Windows server sidecar stage runs both the Windows primary and the
+ // WSL Linux backend from one dependency tree, so it needs win32 + linux
+ // natives (e.g. @yuuang/ffi-rs-linux-x64-gnu) — and a hoisted (physical,
+ // symlink-free) node_modules: the tree gets packed into server.asar and
+ // later extracted for WSL, and neither step can rely on pnpm's
+ // symlink/junction layout surviving the trip.
+ readonly linuxServerBackend?: boolean;
}): StageWorkspaceConfig {
- const { platform, arch, allowBuilds, patchedDependencies, overrides } = input;
+ const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input;
const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch];
- // Linux AppImages and Windows WSL backends both execute a Linux/glibc Node
- // process that loads Linux-native optional deps at runtime (e.g.
- // @yuuang/ffi-rs-linux-x64-gnu). Keep libc explicit so pnpm includes those
- // optional packages in the staged production install.
+ // Linux AppImages execute a Linux/glibc Node process that loads
+ // Linux-native optional deps at runtime. Keep libc explicit so pnpm
+ // includes those optional packages in the staged production install.
const supportedArchitectures =
platform === "linux"
? {
@@ -1034,7 +1158,7 @@ export function createStageWorkspaceConfig(input: {
cpu: hostCpu,
libc: ["glibc"],
}
- : platform === "win"
+ : linuxServerBackend
? {
os: Array.from(new Set([hostOs, "linux"])),
cpu: hostCpu,
@@ -1052,6 +1176,7 @@ export function createStageWorkspaceConfig(input: {
? { patchedDependencies }
: {}),
...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),
+ ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}),
};
}
@@ -1411,36 +1536,27 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin
);
const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")(
- function* (input: { readonly stageDistDir: string; readonly verbose: boolean }) {
+ function* (input: { readonly asarPath: string; readonly verbose: boolean }) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
- // electron-builder names this win-unpacked, win-arm64-unpacked, and so on.
- const distEntries = yield* fs
- .readDirectory(input.stageDistDir)
- .pipe(Effect.orElseSucceed(() => [] as Array));
- let unpackedRoot: string | null = null;
- for (const entry of distEntries) {
- const candidate = path.join(input.stageDistDir, entry, "resources/app.asar.unpacked");
- if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
- unpackedRoot = candidate;
- break;
- }
- }
- // Nothing to verify rather than silently passing: a packaging layout change
- // should surface here instead of turning the check into a no-op.
- if (unpackedRoot === null) {
- return yield* new BundleNotSelfContainedError({
- exitCode: -1,
- output: `No */resources/app.asar.unpacked directory under ${input.stageDistDir}; the bundle self-containment check found nothing to verify.`,
- });
- }
-
const probeRoot = yield* fs.makeTempDirectoryScoped({
prefix: "t3code-bundle-selfcheck-",
});
+ const extractedApp = path.join(probeRoot, "extracted");
const probeApp = path.join(probeRoot, "app");
- yield* copyDirectoryPreservingSymlinks(unpackedRoot, probeApp);
+ yield* Effect.try({
+ try: () => extractAll(input.asarPath, extractedApp),
+ catch: (cause) =>
+ new BundleNotSelfContainedError({
+ exitCode: -1,
+ output: `Could not extract ${input.asarPath} for the bundle self-containment check: ${String(cause)}`,
+ }),
+ });
+ // Keep the existing symlink isolation guard even though the sidecar stage
+ // is hoisted and should be physical. A future package-manager layout change
+ // must not let the probe resolve through the build tree.
+ yield* copyDirectoryPreservingSymlinks(extractedApp, probeApp);
// Guard the guard: if anything above the probe provides a node_modules, a
// missing dependency would resolve there and the check would pass while the
@@ -1466,8 +1582,8 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
// missing dependency shows up, without starting a server or touching disk
// state. It does not cover lazily imported externals: node-pty is checked
// by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node
- // and the bun adapters are only covered by the unpack globs and the
- // inlined-native check below.
+ // and the bun adapters are covered by the shared runtime-external closure
+ // and emitted-bundle checks.
yield* runCommand(
ChildProcess.make(
process.execPath,
@@ -1486,7 +1602,10 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel
env: { ...process.env, NODE_PATH: "" },
},
),
- { label: "bundle self-containment check (node bin.mjs --version)", verbose: input.verbose },
+ {
+ label: "server sidecar self-containment check (node bin.mjs --version)",
+ verbose: input.verbose,
+ },
).pipe(
// Printing a version should be immediate. A regression that blocks (on
// stdin, a port, a lock) would otherwise hang release CI until the job
@@ -1878,11 +1997,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
directories: {
buildResources: "apps/desktop/resources",
},
- // Only the Windows WSL backend needs files outside the asar (see
- // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack
- // extracts native libraries, which fff-node finds in app.asar.unpacked.
- ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}),
- extraResources: DESKTOP_EXTRA_RESOURCES,
+ // All platforms keep app.asar fully packed; electron-builder's default
+ // smart unpack extracts native libraries, which loaders find in
+ // app.asar.unpacked. Windows additionally ships the server tree as the
+ // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE).
+ extraResources: [
+ ...DESKTOP_EXTRA_RESOURCES,
+ ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),
+ ],
};
const updateChannel = resolveDesktopUpdateChannel(version);
const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
@@ -1942,6 +2064,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
if (platform === "win") {
buildConfig.npmRebuild = false;
+ // Keep blockmap-based differential downloads enabled while changing the
+ // installed file topology. The optimization is in the payload shape, not
+ // in trading update bandwidth for install speed.
+ buildConfig.nsis = { differentialPackage: true };
const winConfig: Record = {
target: [target],
icon: "icon.ico",
@@ -2050,6 +2176,381 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (
);
});
+// Stage and pack the Windows server sidecar: the bundled server plus a hoisted
+// install of only its runtime-external/native dependency closure for win32 and
+// WSL Linux. The Windows primary runs from the archive through the asar-aware
+// ELECTRON_RUN_AS_NODE runtime; enabling WSL extracts it to a real directory.
+// Shipping one packed archive instead of thousands of loose files is what
+// makes the NSIS install/update fast.
+export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: {
+ readonly sourceDir: string;
+ readonly asarPath: string;
+}) {
+ const fs = yield* FileSystem.FileSystem;
+ yield* Effect.tryPromise({
+ try: () =>
+ createPackageWithOptions(input.sourceDir, input.asarPath, {
+ dot: true,
+ unpack: WINDOWS_SERVER_ASAR_UNPACK_GLOB,
+ globOptions: { ignore: [...WINDOWS_SERVER_ASAR_IGNORE_GLOBS] },
+ }),
+ catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }),
+ });
+ const unpackedDirPath = `${input.asarPath}.unpacked`;
+ if (!(yield* fs.exists(unpackedDirPath))) {
+ return yield* new WindowsServerSidecarPackError({
+ asarPath: input.asarPath,
+ cause: new Error(`expected native binaries at ${unpackedDirPath}, but none were unpacked`),
+ });
+ }
+});
+
+export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(function* (input: {
+ readonly stageRoot: string;
+ readonly repoRoot: string;
+ readonly serverDistDir: string;
+ readonly arch: typeof BuildArch.Type;
+ readonly appVersion: string;
+ readonly runtimeExternalDependencies: Record;
+ readonly fffNodeVersion: string;
+ readonly allowBuilds: Record;
+ readonly patchedDependencies: Record;
+ readonly overrides: Record;
+ readonly wslPrebuildPath: string | undefined;
+ readonly asarPath: string;
+ readonly verbose: boolean;
+}) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+
+ const serverStageDir = path.join(input.stageRoot, "server");
+ yield* fs.makeDirectory(path.join(serverStageDir, "apps/server"), { recursive: true });
+ yield* fs.copy(input.serverDistDir, path.join(serverStageDir, "apps/server/dist"));
+
+ const sidecarDependencies = {
+ ...input.runtimeExternalDependencies,
+ // The sidecar serves two processes: the Windows primary loads win32
+ // natives, and the WSL backend loads the matching Linux natives (fff via
+ // ffi-rs) from the extracted copy of this same tree.
+ ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion),
+ ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion),
+ };
+ const sidecarPatchedDependencies = createStagePatchedDependencies(
+ input.patchedDependencies,
+ sidecarDependencies,
+ );
+ const sidecarPackageJson = {
+ name: "t3code-server",
+ version: input.appVersion,
+ private: true,
+ packageManager: rootPackageJson.packageManager,
+ dependencies: sidecarDependencies,
+ };
+ const sidecarPackageJsonString = yield* encodeJsonString(sidecarPackageJson);
+ yield* fs.writeFileString(
+ path.join(serverStageDir, "package.json"),
+ `${sidecarPackageJsonString}\n`,
+ );
+ const sidecarWorkspaceConfig = createStageWorkspaceConfig({
+ platform: "win",
+ arch: input.arch,
+ allowBuilds: input.allowBuilds,
+ patchedDependencies: sidecarPatchedDependencies,
+ overrides: input.overrides,
+ linuxServerBackend: true,
+ });
+ const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig);
+ yield* fs.writeFileString(
+ path.join(serverStageDir, "pnpm-workspace.yaml"),
+ sidecarWorkspaceConfigString,
+ );
+ if (Object.keys(sidecarPatchedDependencies).length > 0) {
+ yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(serverStageDir, "patches"));
+ }
+
+ yield* Effect.log("[desktop-artifact] Installing server sidecar runtime externals...");
+ const installCommand = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]);
+ yield* runCommand(
+ ChildProcess.make(installCommand.command, installCommand.args, {
+ cwd: serverStageDir,
+ shell: installCommand.shell,
+ }),
+ { label: "vp install --prod (server sidecar)", verbose: input.verbose },
+ );
+
+ yield* stageWslNodePtyPrebuild({
+ stageAppDir: serverStageDir,
+ arch: input.arch,
+ prebuildPath: input.wslPrebuildPath,
+ });
+
+ yield* Effect.log("[desktop-artifact] Packing server.asar...");
+ yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true });
+ yield* packWindowsServerAsar({ sourceDir: serverStageDir, asarPath: input.asarPath });
+ const packedStat = yield* fs.stat(input.asarPath);
+ yield* Effect.log(
+ `[desktop-artifact] Packed server.asar (${String(packedStat.size)} bytes) + unpacked natives.`,
+ );
+});
+
+function collectUnpackedAsarFiles(
+ directory: DirectoryRecord,
+ parentPath = "",
+ output: string[] = [],
+): readonly string[] {
+ for (const [name, entry] of Object.entries(directory.files)) {
+ const entryPath = parentPath.length === 0 ? name : `${parentPath}/${name}`;
+ if ("files" in entry) {
+ collectUnpackedAsarFiles(entry, entryPath, output);
+ } else if (entry.unpacked) {
+ output.push(entryPath);
+ }
+ }
+ return output;
+}
+
+const countPayloadFiles = Effect.fn("desktopArtifact.countPayloadFiles")(function* (root: string) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const pendingDirectories = [root];
+ let count = 0;
+
+ while (pendingDirectories.length > 0) {
+ const directory = pendingDirectories.pop();
+ if (directory === undefined) break;
+ const entries = yield* fs.readDirectory(directory);
+ for (const entry of entries) {
+ const entryPath = path.join(directory, entry);
+ const stat = yield* fs.stat(entryPath);
+ if (stat.type === "Directory") {
+ pendingDirectories.push(entryPath);
+ } else if (stat.type === "File") {
+ count += 1;
+ }
+ }
+ }
+
+ return count;
+});
+
+export const verifyWindowsPrimaryFffNativeLoad = Effect.fn(
+ "desktopArtifact.verifyWindowsPrimaryFffNativeLoad",
+)(function* (input: {
+ readonly packagedAppDir: string;
+ readonly asarPath: string;
+ readonly appExecutableName: string;
+ readonly targetArch: typeof BuildArch.Type;
+ readonly verbose: boolean;
+}) {
+ const hostPlatform = yield* HostProcessPlatform;
+ const hostArchitecture = yield* HostProcessArchitecture;
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const executablePath = path.join(input.packagedAppDir, input.appExecutableName);
+ const executableStat = yield* fs.stat(executablePath).pipe(Effect.orElseSucceed(() => null));
+ if (executableStat?.type !== "File") {
+ return yield* new WindowsPrimaryNativeProbeError({
+ executablePath,
+ exitCode: -1,
+ output: "The unpacked application does not contain its expected primary executable.",
+ });
+ }
+ if (hostPlatform !== "win32" || hostArchitecture !== input.targetArch) return;
+
+ const probeRoot = yield* fs.makeTempDirectoryScoped({
+ prefix: "t3code-windows-primary-native-probe-",
+ });
+ const fffEntryPath = path.join(
+ input.asarPath,
+ "node_modules/@ff-labs/fff-node/dist/src/index.js",
+ );
+ const probeEnv = { ...process.env };
+ delete probeEnv.ELECTRON_NO_ASAR;
+ delete probeEnv.NODE_OPTIONS;
+
+ yield* runCommand(
+ ChildProcess.make(
+ executablePath,
+ [
+ "--no-global-search-paths",
+ "--input-type=module",
+ "--eval",
+ WINDOWS_PRIMARY_FFF_PROBE_SOURCE,
+ fffEntryPath,
+ probeRoot,
+ ],
+ {
+ cwd: input.packagedAppDir,
+ stdout: "pipe",
+ stderr: "pipe",
+ env: {
+ ...probeEnv,
+ ELECTRON_RUN_AS_NODE: "1",
+ NODE_PATH: "",
+ },
+ },
+ ),
+ {
+ label: "Windows primary fff native-load probe",
+ verbose: input.verbose,
+ },
+ ).pipe(
+ Effect.timeout(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT),
+ Effect.catchTags({
+ TimeoutError: () =>
+ Effect.fail(
+ new WindowsPrimaryNativeProbeError({
+ executablePath,
+ exitCode: -1,
+ output: `The native-load probe did not finish within ${Duration.toSeconds(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT)}s.`,
+ }),
+ ),
+ BuildCommandFailedError: (error) =>
+ Effect.fail(
+ new WindowsPrimaryNativeProbeError({
+ executablePath,
+ exitCode: error.exitCode,
+ output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(),
+ }),
+ ),
+ }),
+ );
+});
+
+export const validateWindowsPackagedPayload = Effect.fn(
+ "desktopArtifact.validateWindowsPackagedPayload",
+)(function* (input: {
+ readonly stageDistDir: string;
+ readonly appExecutableName: string;
+ readonly targetArch: typeof BuildArch.Type;
+ readonly fileLimit?: number;
+ readonly verbose?: boolean;
+}) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const fileLimit = input.fileLimit ?? WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT;
+ const isFile = (filePath: string) =>
+ fs.stat(filePath).pipe(
+ Effect.map((stat) => stat.type === "File"),
+ Effect.orElseSucceed(() => false),
+ );
+ const stageEntries = yield* fs.readDirectory(input.stageDistDir);
+ let packagedAppDir: string | undefined;
+
+ for (const entry of stageEntries) {
+ if (!entry.endsWith("-unpacked")) continue;
+ const candidate = path.join(input.stageDistDir, entry);
+ const stat = yield* fs.stat(candidate).pipe(Effect.orElseSucceed(() => null));
+ if (stat?.type === "Directory") {
+ packagedAppDir = candidate;
+ break;
+ }
+ }
+
+ if (packagedAppDir === undefined) {
+ return yield* new WindowsPackagedPayloadValidationError({
+ reason: "packaged-app-missing",
+ packagedAppDir: path.join(input.stageDistDir, "win-unpacked"),
+ });
+ }
+
+ const resourcesDir = path.join(packagedAppDir, "resources");
+ const asarPath = path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE);
+ if (!(yield* fs.exists(asarPath).pipe(Effect.orElseSucceed(() => false)))) {
+ return yield* new WindowsPackagedPayloadValidationError({
+ reason: "sidecar-missing",
+ packagedAppDir,
+ missingFiles: [WINDOWS_SERVER_ASAR_RESOURCE],
+ });
+ }
+
+ const unpackedFiles = yield* Effect.try({
+ try: () => {
+ // The entry lookup proves the archive contains the server executable,
+ // while the single header walk identifies every file ASAR redirects to
+ // the unpacked sibling at runtime.
+ // @electron/asar resolves entry names using the host path separator.
+ // POSIX separators work on Linux/macOS but fail on Windows even when the
+ // entry is present in the archive.
+ statFile(asarPath, path.join("apps", "server", "dist", "bin.mjs"));
+ return [...collectUnpackedAsarFiles(getRawHeader(asarPath).header)].sort();
+ },
+ catch: (cause) =>
+ new WindowsPackagedPayloadValidationError({
+ reason: "sidecar-invalid",
+ packagedAppDir,
+ cause,
+ }),
+ });
+ if (unpackedFiles.length === 0) {
+ return yield* new WindowsPackagedPayloadValidationError({
+ reason: "sidecar-invalid",
+ packagedAppDir,
+ cause: new Error("server.asar does not declare any unpacked native files"),
+ });
+ }
+
+ const missingFiles: string[] = [];
+ for (const unpackedFile of unpackedFiles) {
+ const unpackedPath = path.join(
+ resourcesDir,
+ `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`,
+ ...unpackedFile.split("/"),
+ );
+ if (!(yield* isFile(unpackedPath))) {
+ missingFiles.push(`${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/${unpackedFile}`);
+ }
+ }
+ if (missingFiles.length > 0) {
+ return yield* new WindowsPackagedPayloadValidationError({
+ reason: "unpacked-native-missing",
+ packagedAppDir,
+ missingFiles,
+ });
+ }
+
+ const resourceMonitorPath = path.join(
+ resourcesDir,
+ "resource-monitor",
+ resourceMonitorExecutableName("win"),
+ );
+ if (!(yield* isFile(resourceMonitorPath))) {
+ return yield* new WindowsPackagedPayloadValidationError({
+ reason: "resource-monitor-missing",
+ packagedAppDir,
+ missingFiles: ["resource-monitor/t3-resource-monitor.exe"],
+ });
+ }
+
+ const fileCount = yield* countPayloadFiles(packagedAppDir);
+ if (fileCount > fileLimit) {
+ return yield* new WindowsPackagedPayloadValidationError({
+ reason: "file-limit-exceeded",
+ packagedAppDir,
+ fileCount,
+ fileLimit,
+ });
+ }
+
+ yield* verifyWindowsPrimaryFffNativeLoad({
+ packagedAppDir,
+ asarPath,
+ appExecutableName: input.appExecutableName,
+ targetArch: input.targetArch,
+ verbose: input.verbose ?? false,
+ });
+
+ yield* verifyPackagedBundleIsSelfContained({
+ asarPath,
+ verbose: input.verbose ?? false,
+ });
+
+ yield* Effect.log(
+ `[desktop-artifact] Validated Windows payload (${String(fileCount)} files, ${String(unpackedFiles.length)} sidecar natives).`,
+ );
+ return { packagedAppDir, fileCount, unpackedFiles } as const;
+});
+
const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
options: ResolvedBuildOptions,
) {
@@ -2098,6 +2599,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
cause,
}),
});
+ const resolvedServerRuntimeExternalDependencies = selectCliRuntimeExternalDependencies(
+ resolvedServerDependencies,
+ );
const resolvedDesktopRuntimeDependencies = yield* Effect.try({
try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog),
catch: (cause) =>
@@ -2188,7 +2692,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
// inlined. A regression to externalizing everything would also pass it,
// since source-file regions still exist -- and that is the failure this
// whole change exists to prevent, because those packages are not in the
- // unpack globs and the WSL backend would die on ERR_MODULE_NOT_FOUND.
+ // selected sidecar closure and both backends would die on ERR_MODULE_NOT_FOUND.
// `effect` is imported by every server module, so it is inlined in any
// correctly bundled build.
// The list-based check above only sees packages someone already thought to
@@ -2227,12 +2731,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
yield* validateBundledClientAssets(path.dirname(bundledClientEntry));
yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true });
- yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true });
+ if (options.platform !== "win") {
+ yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true });
+ }
yield* Effect.log("[desktop-artifact] Staging release app...");
yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
- yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
+ // On Windows the server tree ships in the server.asar sidecar instead of
+ // app.asar (see stageWindowsServerSidecar), so the app stage omits it.
+ if (options.platform !== "win") {
+ yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
+ }
yield* stageResourceMonitor({
repoRoot,
stageResourcesDir,
@@ -2253,7 +2763,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
);
// electron-builder is filtering out stageResourcesDir directory in the AppImage for production
- yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));
+ const stageProdResourcesDir = path.join(stageAppDir, "apps/desktop/prod-resources");
+ yield* fs.copy(stageResourcesDir, stageProdResourcesDir);
const configuredMacPasskeySigning =
options.platform === "mac" && options.signed
@@ -2283,30 +2794,31 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning));
}
- const stageDependencies = {
- ...resolvedServerDependencies,
- ...resolvedDesktopRuntimeDependencies,
- ...resolveFffNativeDependencies(
- options.platform,
- options.arch,
- serverPackageJson.dependencies["@ff-labs/fff-node"],
- ),
- // Windows artifacts also bundle the same-architecture WSL Linux backend, which loads the
- // fff native binary through ffi-rs. The platform fff binary above is the
- // host's (win32), so promote the matching Linux fff binaries too; without
- // them file-finding in WSL fails to load its Linux native package.
- ...(options.platform === "win"
- ? resolveFffNativeDependencies(
- "linux",
- options.arch,
- serverPackageJson.dependencies["@ff-labs/fff-node"],
- )
- : {}),
- };
+ // Windows splits dependencies per process: app.asar carries only the
+ // desktop main-process runtime deps, while the server bundle's deps live in
+ // the server.asar sidecar (see stageWindowsServerSidecar). macOS and Linux
+ // keep the single merged tree — their primary resolves everything from
+ // app.asar and there is no second consumer.
+ const stageDependencies =
+ options.platform === "win"
+ ? { ...resolvedDesktopRuntimeDependencies }
+ : {
+ ...resolvedServerDependencies,
+ ...resolvedDesktopRuntimeDependencies,
+ ...resolveFffNativeDependencies(
+ options.platform,
+ options.arch,
+ serverPackageJson.dependencies["@ff-labs/fff-node"],
+ ),
+ };
const stagePatchedDependencies = createStagePatchedDependencies(
workspacePatchedDependencies,
stageDependencies,
);
+ const windowsServerAsarPath =
+ options.platform === "win"
+ ? path.join(stageAppDir, WINDOWS_SERVER_RESOURCE_SOURCE_DIR, WINDOWS_SERVER_ASAR_RESOURCE)
+ : undefined;
const stagePackageJson: StagePackageJson = {
name: "t3code",
version: appVersion,
@@ -2367,13 +2879,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
);
yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);
- // WSL is Windows-only, so only the Windows artifact carries the Linux backend
- // binary; other platforms ignore the prebuild input.
- if (options.platform === "win") {
- yield* stageWslNodePtyPrebuild({
- stageAppDir,
+ // WSL is Windows-only, so only the Windows artifact carries the server
+ // sidecar (which embeds the Linux node-pty prebuild); other platforms
+ // ignore the prebuild input.
+ if (options.platform === "win" && windowsServerAsarPath) {
+ yield* stageWindowsServerSidecar({
+ stageRoot,
+ repoRoot,
+ serverDistDir: distDirs.serverDist,
arch: options.arch,
- prebuildPath: options.wslPrebuild,
+ appVersion,
+ runtimeExternalDependencies: resolvedServerRuntimeExternalDependencies,
+ fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"],
+ allowBuilds: workspaceAllowBuilds,
+ patchedDependencies: workspacePatchedDependencies,
+ overrides: resolvedOverrides,
+ wslPrebuildPath: options.wslPrebuild,
+ asarPath: windowsServerAsarPath,
+ verbose: options.verbose,
});
}
@@ -2462,9 +2985,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
// resolver has no such ambiguity: it either finds every import or it does not.
//
// Only Windows unpacks anything; macOS and Linux keep the whole tree inside
- // the asar, where this check has nothing to look at.
+ // the app asar. Windows validates and executes the separately packed server
+ // sidecar after electron-builder copies it into the final payload.
if (options.platform === "win") {
- yield* verifyPackagedBundleIsSelfContained({ stageDistDir, verbose: options.verbose });
+ yield* validateWindowsPackagedPayload({
+ stageDistDir,
+ appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`,
+ targetArch: options.arch,
+ verbose: options.verbose,
+ });
}
const stageEntries = yield* fs.readDirectory(stageDistDir);
diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts
index 189634dfe..754cd646f 100644
--- a/scripts/lib/cli-external-packages.test.ts
+++ b/scripts/lib/cli-external-packages.test.ts
@@ -7,11 +7,12 @@ import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
+import serverPackageJson from "../../apps/server/package.json" with { type: "json" };
+
import {
- CLI_EXTERNAL_PACKAGE_PREFIXES,
- CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
CLI_RUNTIME_EXTERNAL_PREFIXES,
findInlinedExternalPackages,
+ selectCliRuntimeExternalDependencies,
shouldBundleCliDependency,
} from "./cli-external-packages.ts";
@@ -60,39 +61,41 @@ describe("shouldBundleCliDependency", () => {
});
// The real package is `node-gyp-build-optional-packages`, reached by prefix.
- // Matching it as external while failing to unpack it is invisible on the
- // Windows primary (which reads app.asar) and breaks only under WSL.
+ // It is transitive to a selected dependency root, so the runtime closure test
+ // below ensures it follows that root into the sidecar.
it("treats prefix-matched siblings as external", () => {
assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false);
});
});
-describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => {
- it("unpacks every external prefix from both the top level and the pnpm store", () => {
- for (const prefix of CLI_EXTERNAL_PACKAGE_PREFIXES) {
- assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, `node_modules/${prefix}*/**/*`, prefix);
- assert.include(
- CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS,
- `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`,
- prefix,
- );
- }
+describe("selectCliRuntimeExternalDependencies", () => {
+ it("keeps only runtime-external dependency roots for the Windows sidecar", () => {
+ assert.deepStrictEqual(
+ selectCliRuntimeExternalDependencies({
+ "@effect/platform-bun": "1.0.0",
+ "@ff-labs/fff-node": "2.0.0",
+ effect: "3.0.0",
+ "node-pty": "4.0.0",
+ }),
+ {
+ "@ff-labs/fff-node": "2.0.0",
+ "node-pty": "4.0.0",
+ },
+ );
});
- // Without the trailing `*` the globs stop covering prefix-matched siblings,
- // which is exactly how a package ends up external but not unpacked.
- it("keeps the trailing wildcard that matches prefix siblings", () => {
- assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, "node_modules/node-gyp-build*/**/*");
+ it("selects every external root declared by the server", () => {
+ assert.deepStrictEqual(
+ Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(),
+ ["@ff-labs/fff-node", "msgpackr-extract", "node-pty"],
+ );
});
});
-// The failure this guards is invisible on Windows and fatal under WSL.
-//
// An external package is loaded from the real filesystem, so its own `require`
// also resolves from the real filesystem. If one of its dependencies was
-// bundled away instead of left external, that dependency exists only inside
-// app.asar — which the Windows primary reads transparently under
-// ELECTRON_RUN_AS_NODE, and plain `node` under WSL cannot.
+// bundled away instead of left external, that dependency does not follow the
+// selected root into the sidecar.
//
// Found the hard way: node-gyp-build-optional-packages requires detect-libc,
// which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND.
@@ -103,8 +106,8 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => {
// by name from this file at all, and an `exports` map can refuse the
// `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not
// installed", which would let this test skip everything and pass while
- // checking nothing. The store is also what asarUnpack globs target, so this
- // reads the same tree the build packages.
+ // checking nothing. The store contains the dependency graph the sidecar's
+ // minimal production install resolves.
const readInstalledPackages = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts
index f50718af4..d7a89bc40 100644
--- a/scripts/lib/cli-external-packages.ts
+++ b/scripts/lib/cli-external-packages.ts
@@ -4,14 +4,12 @@
* Two consumers derive from this list, and they must never disagree:
*
* - apps/server/vite.config.ts decides what stays external to the bundle.
- * - scripts/build-desktop-artifact.ts decides what gets unpacked out of the asar.
+ * - scripts/build-desktop-artifact.ts selects the runtime dependency roots for
+ * the Windows server sidecar.
*
- * A package that is external but not unpacked still resolves on the Windows
- * primary, which runs under ELECTRON_RUN_AS_NODE and reads app.asar
- * transparently. It fails only under WSL, where the backend is launched as plain
- * `wsl.exe -- node` and cannot read inside an archive. That asymmetry makes the
- * drift invisible on the platform you are most likely to test on, which is why
- * both consumers derive from one list instead of maintaining their own.
+ * A runtime package that is external but absent from the sidecar fails as soon
+ * as Node resolves it from the emitted bundle. Keeping both consumers on one
+ * list prevents packaging from drifting away from the bundle boundary.
*
* Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover
* a package's platform-specific siblings — `node-gyp-build` covers
@@ -24,8 +22,8 @@
* critically — the ordinary JS packages those wrappers require. An external
* package is loaded from the real filesystem, so its own `require` also
* resolves from the real filesystem; a dependency that was bundled away exists
- * only inside app.asar and is unreachable there. This closure is enforced by a
- * test, not by inspection.
+ * only inside the emitted bundle and is unreachable there. This closure is
+ * enforced by a test, not by inspection.
*/
export const CLI_RUNTIME_EXTERNAL_PREFIXES = [
"node-pty",
@@ -70,6 +68,10 @@ export const CLI_EXTERNAL_PACKAGE_PREFIXES = [
...CLI_BUILD_ONLY_EXTERNAL_PREFIXES,
] as const;
+export function isRuntimeExternalCliDependency(id: string): boolean {
+ return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix));
+}
+
/**
* True when `id` must stay out of the bundle.
*
@@ -90,20 +92,14 @@ export function shouldBundleCliDependency(id: string): boolean {
return !isExternalCliDependency(id);
}
-/**
- * asar-unpack globs covering every external package.
- *
- * The trailing `*` is what keeps these aligned with the prefix matching above:
- * without it, `node-gyp-build` would be left external by the bundler and then
- * not unpacked, because the real package is `node-gyp-build-optional-packages`.
- *
- * pnpm stores real files under `.pnpm` and symlinks the top-level names, so both
- * paths are unpacked for the link target to exist on disk.
- */
-export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.flatMap(
- (prefix) =>
- [`node_modules/${prefix}*/**/*`, `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`] as const,
-);
+/** Select direct dependency roots whose runtime closure belongs in the sidecar. */
+export function selectCliRuntimeExternalDependencies(
+ dependencies: Readonly>,
+): Record {
+ return Object.fromEntries(
+ Object.entries(dependencies).filter(([name]) => isRuntimeExternalCliDependency(name)),
+ );
+}
/**
* Scan an emitted bundle chunk for runtime-external packages that were inlined.
@@ -122,8 +118,8 @@ export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.f
* check the opposite direction too. Verifying only that externals are absent
* would still pass if the bundler reverted to leaving everything external: the
* scan would see source-file regions, report nothing inlined, and the packaged
- * WSL backend would then fail with ERR_MODULE_NOT_FOUND because those packages
- * are not in the unpack globs either.
+ * backends would then fail with ERR_MODULE_NOT_FOUND because those packages
+ * are not in the selected sidecar closure either.
*/
export function findInlinedExternalPackages(source: string): {
readonly regionCount: number;
diff --git a/scripts/package.json b/scripts/package.json
index 457a8f0d3..14c4ea98e 100644
--- a/scripts/package.json
+++ b/scripts/package.json
@@ -8,6 +8,7 @@
},
"dependencies": {
"@effect/platform-node": "catalog:",
+ "@electron/asar": "^3.4.1",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/tailscale": "workspace:*",
From 196c8ea0d642acd1db66bd57f5d98abe81d8da6e Mon Sep 17 00:00:00 2001
From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 19:43:33 +0000
Subject: [PATCH 5/5] fix(web): style sidebar action tooltips (#6371)
Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com>
Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>
---
apps/web/src/components/Sidebar.tsx | 82 ++++++++++++++++++-----------
1 file changed, 51 insertions(+), 31 deletions(-)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index f35dd1fdb..dc8be07dc 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -366,19 +366,26 @@ function SnoozePopoverButton(props: {
);
return (
- event.stopPropagation()}
- onDoubleClick={(event) => event.stopPropagation()}
- className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
- />
- }
- >
-
-
+
+ event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
+ />
+ }
+ />
+ }
+ >
+
+
+ Snooze thread
+
{presets.map((preset) => (
+
+
+ }
+ >
+
+
+ Unpin thread
+
) : (
) : null}
{props.settlementSupported ? (
-
+
+
+ }
+ >
+
+ Settle
+
+ Settle thread
+
) : null}
) : null}