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}