diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cc0d096f9e..56c5dd518d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,7 +225,7 @@ jobs: --root "$unpackedRoot" ` --max-files 250 - - name: Assert launch smoke rejects missing unpacked backend entry + - name: Assert launch smoke reports missing unpacked runtime file shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -240,11 +240,11 @@ jobs: Copy-Item -Recurse -Force (Join-Path $cleanInstallDir "*") $brokenInstallDir $brokenAppExe = Join-Path $brokenInstallDir (Split-Path -Leaf $cleanAppExe) - $removedBackendEntry = Join-Path $brokenInstallDir "resources/app.asar.unpacked/apps/server/dist/bin.mjs" - if (-not (Test-Path $removedBackendEntry)) { - throw "Negative launch-smoke fixture could not find packaged backend entry to remove: $removedBackendEntry" + $removedRuntimeFile = Join-Path $brokenInstallDir "resources/app.asar.unpacked/apps/server/dist/bin.mjs" + if (-not (Test-Path $removedRuntimeFile)) { + throw "Negative launch-smoke fixture could not find packaged runtime file to remove: $removedRuntimeFile" } - Remove-Item -Force $removedBackendEntry + Remove-Item -Force $removedRuntimeFile $output = & node scripts/desktop-launch-smoke.mjs --command "$brokenAppExe" --timeout-ms 45000 --stability-ms 1000 2>&1 $exitCode = $LASTEXITCODE @@ -252,10 +252,10 @@ jobs: $output | ForEach-Object { Write-Host $_ } if ($exitCode -eq 0) { - throw "Launch smoke passed against a packaged artifact with the backend entry removed; the gate is not trustworthy." + throw "Launch smoke passed against a packaged artifact with a required unpacked runtime file removed; the gate is not trustworthy." } - if ($outputText -notmatch "(?i)(apps/server/dist/bin\.mjs|missing server entry|backend|timed out)") { - throw "Launch smoke failed against the broken packaged artifact, but did not report the expected missing backend entry." + if ($outputText -notmatch "(?i)(installation is incomplete|apps/server/dist/bin\.mjs)") { + throw "Launch smoke failed against the broken packaged artifact, but did not report the expected incomplete-installation diagnostic." } Write-Host "Launch smoke rejected the deliberately broken packaged artifact with exit code $exitCode." diff --git a/.github/workflows/reusable-build-release-artifacts.yml b/.github/workflows/reusable-build-release-artifacts.yml index d1e2054c5f1..94eead1bcdf 100644 --- a/.github/workflows/reusable-build-release-artifacts.yml +++ b/.github/workflows/reusable-build-release-artifacts.yml @@ -470,7 +470,7 @@ jobs: --root "$unpackedRoot" ` --max-files 250 - - name: Assert launch smoke rejects missing unpacked backend entry + - name: Assert launch smoke reports missing unpacked runtime file shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -485,11 +485,11 @@ jobs: Copy-Item -Recurse -Force (Join-Path $cleanInstallDir "*") $brokenInstallDir $brokenAppExe = Join-Path $brokenInstallDir (Split-Path -Leaf $cleanAppExe) - $removedBackendEntry = Join-Path $brokenInstallDir "resources/app.asar.unpacked/apps/server/dist/bin.mjs" - if (-not (Test-Path $removedBackendEntry)) { - throw "Negative launch-smoke fixture could not find packaged backend entry to remove: $removedBackendEntry" + $removedRuntimeFile = Join-Path $brokenInstallDir "resources/app.asar.unpacked/apps/server/dist/bin.mjs" + if (-not (Test-Path $removedRuntimeFile)) { + throw "Negative launch-smoke fixture could not find packaged runtime file to remove: $removedRuntimeFile" } - Remove-Item -Force $removedBackendEntry + Remove-Item -Force $removedRuntimeFile $output = & node scripts/desktop-launch-smoke.mjs --command "$brokenAppExe" --timeout-ms 45000 --stability-ms 1000 2>&1 $exitCode = $LASTEXITCODE @@ -497,10 +497,10 @@ jobs: $output | ForEach-Object { Write-Host $_ } if ($exitCode -eq 0) { - throw "Launch smoke passed against a packaged artifact with the backend entry removed; the gate is not trustworthy." + throw "Launch smoke passed against a packaged artifact with a required unpacked runtime file removed; the gate is not trustworthy." } - if ($outputText -notmatch "(?i)(apps/server/dist/bin\.mjs|missing server entry|backend|timed out)") { - throw "Launch smoke failed against the broken packaged artifact, but did not report the expected missing backend entry." + if ($outputText -notmatch "(?i)(installation is incomplete|apps/server/dist/bin\.mjs)") { + throw "Launch smoke failed against the broken packaged artifact, but did not report the expected incomplete-installation diagnostic." } Write-Host "Launch smoke rejected the deliberately broken packaged artifact with exit code $exitCode." diff --git a/apps/desktop/scripts/desktop-update-smoke.mjs b/apps/desktop/scripts/desktop-update-smoke.mjs new file mode 100644 index 00000000000..e50f4d2d8be --- /dev/null +++ b/apps/desktop/scripts/desktop-update-smoke.mjs @@ -0,0 +1,565 @@ +#!/usr/bin/env node +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeTimersPromises from "node:timers/promises"; +import * as NodeURL from "node:url"; + +import { _electron as electron } from "playwright-core"; + +const DEFAULT_TIMEOUT_MS = 240_000; +const DESCRIPTOR_PATH = "/.well-known/t3/environment"; +const READY_MARKER_FILE = "main-window-ready.json"; +const MAX_CAPTURED_OUTPUT_BYTES = 256 * 1024; +const BACKEND_LOOPBACK_HOST = "127.0.0.1"; +const UPDATE_SERVER_HOST = "127.0.0.1"; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone CI script. +const hostPlatform = process.platform; +const repoRoot = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../../..", +); + +function usage() { + return [ + "usage: node apps/desktop/scripts/desktop-update-smoke.mjs --command --update-root --expected-to-version [--expected-from-version ]", + "", + "Launches an installed packaged desktop app, serves a local update feed,", + "drives check/download/install through the preload bridge, and waits for", + "the updated app to relaunch and signal the expected version.", + ].join("\n"); +} + +function parseArgs(argv) { + const options = { + command: undefined, + updateRoot: undefined, + expectedFromVersion: undefined, + expectedToVersion: undefined, + updateServerPort: undefined, + timeoutMs: DEFAULT_TIMEOUT_MS, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const value = argv[index + 1]; + switch (arg) { + case "--command": + if (!value) throw new Error("--command requires a value"); + options.command = value; + index += 1; + break; + case "--update-root": + if (!value) throw new Error("--update-root requires a value"); + options.updateRoot = value; + index += 1; + break; + case "--expected-from-version": + if (!value) throw new Error("--expected-from-version requires a value"); + options.expectedFromVersion = value; + index += 1; + break; + case "--expected-to-version": + if (!value) throw new Error("--expected-to-version requires a value"); + options.expectedToVersion = value; + index += 1; + break; + case "--update-server-port": + if (!value) throw new Error("--update-server-port requires a value"); + options.updateServerPort = Number.parseInt(value, 10); + if (!Number.isInteger(options.updateServerPort) || options.updateServerPort <= 0) { + throw new Error("--update-server-port must be a positive integer"); + } + index += 1; + break; + case "--timeout-ms": + if (!value) throw new Error("--timeout-ms requires a value"); + options.timeoutMs = Number.parseInt(value, 10); + if (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0) { + throw new Error("--timeout-ms must be a positive integer"); + } + index += 1; + break; + case "--help": + case "-h": + console.log(usage()); + process.exit(0); + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!options.command) throw new Error("Pass --command"); + if (!options.updateRoot) throw new Error("Pass --update-root"); + if (!options.expectedToVersion) throw new Error("Pass --expected-to-version"); + + return { + ...options, + command: NodePath.resolve(options.command), + updateRoot: NodePath.resolve(options.updateRoot), + }; +} + +function appendOutput(current, streamName, chunk) { + const next = `${current}${streamName}: ${chunk.toString()}`; + return next.length > MAX_CAPTURED_OUTPUT_BYTES + ? next.slice(next.length - MAX_CAPTURED_OUTPUT_BYTES) + : next; +} + +function reserveLoopbackPort(hostname = BACKEND_LOOPBACK_HOST) { + return new Promise((resolve, reject) => { + const server = NodeNet.createServer(); + server.once("error", reject); + server.listen(0, hostname, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Could not reserve a loopback TCP port"))); + return; + } + const port = address.port; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +function fetchText(hostname, port, path, timeoutMs) { + return new Promise((resolve, reject) => { + const request = NodeHttp.get( + { + hostname, + port, + path, + timeout: timeoutMs, + }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => { + if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) { + reject(new Error(`${path} returned HTTP ${response.statusCode ?? "unknown"}`)); + return; + } + resolve(body); + }); + }, + ); + request.once("timeout", () => { + request.destroy(new Error(`${path} request timed out after ${timeoutMs}ms`)); + }); + request.once("error", reject); + }); +} + +async function waitForUpdateServer(hostname, port, deadline) { + let lastError; + while (Date.now() < deadline) { + for (const channelFile of ["/latest.yml", "/nightly.yml"]) { + try { + const text = await fetchText(hostname, port, channelFile, 1_000); + return { channelFile, text }; + } catch (error) { + lastError = error; + } + } + await NodeTimersPromises.setTimeout(500); + } + + throw new Error( + `Timed out waiting for mock update server on ${hostname}:${port}: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +} + +function startMockUpdateServer(updateRoot, port) { + const child = NodeChildProcess.spawn( + process.execPath, + [NodePath.join(repoRoot, "scripts/mock-update-server.ts")], + { + cwd: repoRoot, + env: { + ...process.env, + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: String(port), + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT: updateRoot, + }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + + let output = ""; + child.stdout.on("data", (chunk) => { + output = appendOutput(output, "update-server stdout", chunk); + }); + child.stderr.on("data", (chunk) => { + output = appendOutput(output, "update-server stderr", chunk); + }); + + return { + child, + output: () => output, + stop: async () => { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.kill("SIGTERM"); + await NodeTimersPromises.setTimeout(1_000); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }, + }; +} + +async function readReadyMarker(filePath) { + let body; + try { + body = await NodeFSP.readFile(filePath, "utf8"); + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") { + return undefined; + } + throw error; + } + + try { + const parsed = JSON.parse(body); + if (!parsed || typeof parsed !== "object" || parsed.status !== "main-window-ready") { + return undefined; + } + if (typeof parsed.windowId !== "number") { + return undefined; + } + if (typeof parsed.url !== "string" || parsed.url.length === 0) { + return undefined; + } + const url = new URL(parsed.url); + if (url.protocol !== "t3code:" && url.protocol !== "t3code-dev:") { + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +async function waitForReadyMarker(filePath, expectedVersion, deadline) { + let lastMarker; + while (Date.now() < deadline) { + const marker = await readReadyMarker(filePath); + if (marker) { + lastMarker = marker; + if (expectedVersion === undefined || marker.appVersion === expectedVersion) { + return marker; + } + } + await NodeTimersPromises.setTimeout(500); + } + + throw new Error( + `Timed out waiting for ${READY_MARKER_FILE}${ + expectedVersion ? ` with appVersion ${expectedVersion}` : "" + }. Last marker: ${lastMarker ? JSON.stringify(lastMarker) : "none"}`, + ); +} + +async function waitForDescriptor(hostname, port, deadline) { + let lastError; + while (Date.now() < deadline) { + try { + const text = await fetchText(hostname, port, DESCRIPTOR_PATH, 1_000); + const parsed = JSON.parse(text); + if (typeof parsed.environmentId === "string" && parsed.environmentId.length > 0) { + return parsed; + } + } catch (error) { + lastError = error; + } + await NodeTimersPromises.setTimeout(500); + } + + throw new Error( + `Timed out waiting for desktop backend descriptor on ${hostname}:${port}: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +} + +async function pageHasDesktopBridge(page) { + try { + return await page.evaluate(() => Boolean(window.desktopBridge?.getUpdateState)); + } catch { + return false; + } +} + +async function waitForDesktopBridgePage(electronApp, deadline) { + const seenPages = new WeakSet(); + while (Date.now() < deadline) { + for (const page of electronApp.windows()) { + if (!seenPages.has(page)) { + seenPages.add(page); + page.on("console", (message) => { + console.log(`[desktop-update-smoke renderer] ${message.type()}: ${message.text()}`); + }); + } + if (await pageHasDesktopBridge(page)) { + return page; + } + } + + try { + const page = await electronApp.waitForEvent("window", { timeout: 1_000 }); + page.on("console", (message) => { + console.log(`[desktop-update-smoke renderer] ${message.type()}: ${message.text()}`); + }); + if (await pageHasDesktopBridge(page)) { + return page; + } + } catch { + // Keep polling existing windows until the overall deadline. + } + } + + throw new Error("Timed out waiting for a renderer window with desktopBridge."); +} + +async function waitForUpdateState(page, predicate, description, deadline) { + let lastState; + while (Date.now() < deadline) { + lastState = await page.evaluate(() => window.desktopBridge.getUpdateState()); + if (predicate(lastState)) { + return lastState; + } + await NodeTimersPromises.setTimeout(500); + } + + throw new Error( + `Timed out waiting for update state ${description}. Last state: ${JSON.stringify(lastState)}`, + ); +} + +function makeDesktopEnv(input) { + const env = { + ...process.env, + APPDATA: input.appData, + ELECTRON_ENABLE_LOGGING: "1", + NO_AT_BRIDGE: "1", + T3CODE_HOME: input.t3Home, + T3CODE_DESKTOP_MOCK_UPDATES: "1", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: String(input.updateServerPort), + T3CODE_DESKTOP_SMOKE_READY_FILE: input.readyMarkerPath, + T3CODE_DESKTOP_VERIFY_RUNTIME_DEPENDENCIES: "1", + T3CODE_NO_BROWSER: "1", + T3CODE_PORT: String(input.backendPort), + XDG_CONFIG_HOME: input.appData, + }; + delete env.ELECTRON_RUN_AS_NODE; + delete env.VITE_DEV_SERVER_URL; + delete env.T3CODE_DISABLE_AUTO_UPDATE; + return env; +} + +async function launchInstalledApp(executablePath, env, timeoutMs) { + const app = await electron.launch({ + executablePath, + cwd: NodePath.dirname(executablePath), + args: hostPlatform === "linux" ? ["--no-sandbox", "--disable-gpu"] : [], + env, + timeout: Math.min(timeoutMs, 90_000), + }); + + app.on("console", (message) => { + console.log(`[desktop-update-smoke main] ${message.type()}: ${message.text()}`); + }); + + return app; +} + +function terminateInstalledExecutable(executablePath) { + if (hostPlatform === "win32") { + const script = [ + "$ErrorActionPreference = 'SilentlyContinue'", + "$target = [System.IO.Path]::GetFullPath($env:T3CODE_UPDATED_APP_EXE)", + "Get-CimInstance Win32_Process |", + "Where-Object { $_.ExecutablePath -and ([System.IO.Path]::GetFullPath($_.ExecutablePath) -eq $target) } |", + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }", + ].join("\n"); + NodeChildProcess.spawnSync("powershell.exe", ["-NoProfile", "-Command", script], { + env: { + ...process.env, + T3CODE_UPDATED_APP_EXE: executablePath, + }, + stdio: "ignore", + windowsHide: true, + }); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (!NodeFS.existsSync(options.command)) { + throw new Error(`Installed executable does not exist: ${options.command}`); + } + if (!NodeFS.existsSync(options.updateRoot)) { + throw new Error(`Update root does not exist: ${options.updateRoot}`); + } + + const updateServerPort = + options.updateServerPort ?? (await reserveLoopbackPort(UPDATE_SERVER_HOST)); + const backendPort = await reserveLoopbackPort(BACKEND_LOOPBACK_HOST); + const tempRoot = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-desktop-update-smoke-"), + ); + const t3Home = NodePath.join(tempRoot, "t3-home"); + const appData = NodePath.join(tempRoot, "app-data"); + const readyMarkerPath = NodePath.join(tempRoot, READY_MARKER_FILE); + await NodeFSP.mkdir(t3Home, { recursive: true }); + await NodeFSP.mkdir(appData, { recursive: true }); + + const deadline = Date.now() + options.timeoutMs; + const updateServer = startMockUpdateServer(options.updateRoot, updateServerPort); + let electronApp; + + try { + const updateFeed = await waitForUpdateServer(UPDATE_SERVER_HOST, updateServerPort, deadline); + if (!updateFeed.text.includes(`version: ${options.expectedToVersion}`)) { + throw new Error( + `${updateFeed.channelFile} did not advertise expected version ${options.expectedToVersion}.`, + ); + } + console.log( + `[desktop-update-smoke] Mock update server ready on port ${updateServerPort} (${updateFeed.channelFile})`, + ); + + const env = makeDesktopEnv({ + appData, + backendPort, + readyMarkerPath, + t3Home, + updateServerPort, + }); + + console.log(`[desktop-update-smoke] Launching installed app ${options.command}`); + electronApp = await launchInstalledApp(options.command, env, options.timeoutMs); + const page = await waitForDesktopBridgePage(electronApp, deadline); + const appVersion = await electronApp.evaluate(({ app }) => app.getVersion()); + if (options.expectedFromVersion && appVersion !== options.expectedFromVersion) { + throw new Error( + `Installed app version was ${appVersion}, expected ${options.expectedFromVersion}.`, + ); + } + + const descriptor = await waitForDescriptor(BACKEND_LOOPBACK_HOST, backendPort, deadline); + const readyMarker = await waitForReadyMarker( + readyMarkerPath, + options.expectedFromVersion, + deadline, + ); + console.log( + `[desktop-update-smoke] Installed app ready: version=${appVersion} environmentId=${descriptor.environmentId} windowId=${readyMarker.windowId}`, + ); + + const initialState = await page.evaluate(() => window.desktopBridge.getUpdateState()); + if (!initialState.enabled) { + throw new Error(`Desktop updates were disabled: ${initialState.message ?? "no message"}`); + } + + await page.evaluate(() => window.desktopBridge.checkForUpdate()); + const availableState = await waitForUpdateState( + page, + (state) => + (state.status === "available" || state.status === "downloaded") && + (state.availableVersion === options.expectedToVersion || + state.downloadedVersion === options.expectedToVersion), + `available version ${options.expectedToVersion}`, + deadline, + ); + console.log( + `[desktop-update-smoke] Update available: status=${availableState.status} version=${ + availableState.availableVersion ?? availableState.downloadedVersion + }`, + ); + + const downloadResult = await page.evaluate(() => window.desktopBridge.downloadUpdate()); + if (!downloadResult.accepted || !downloadResult.completed) { + throw new Error(`Update download was not completed: ${JSON.stringify(downloadResult)}`); + } + const downloadedState = await waitForUpdateState( + page, + (state) => + state.status === "downloaded" && state.downloadedVersion === options.expectedToVersion, + `downloaded version ${options.expectedToVersion}`, + deadline, + ); + console.log( + `[desktop-update-smoke] Update downloaded: version=${downloadedState.downloadedVersion}`, + ); + + await NodeFSP.rm(readyMarkerPath, { force: true }); + const closePromise = electronApp.waitForEvent("close", { + timeout: Math.max(1, deadline - Date.now()), + }); + const installPromise = page + .evaluate(() => window.desktopBridge.installUpdate()) + .catch((error) => { + if ( + /Target.*closed|browser has been closed|Execution context was destroyed/iu.test( + String(error), + ) + ) { + return { accepted: true, completed: false }; + } + throw error; + }); + const installResult = await installPromise; + if (!installResult.accepted) { + throw new Error(`Update install was not accepted: ${JSON.stringify(installResult)}`); + } + await closePromise; + electronApp = undefined; + console.log("[desktop-update-smoke] Installed app exited for update installation."); + + const updatedMarker = await waitForReadyMarker( + readyMarkerPath, + options.expectedToVersion, + deadline, + ); + console.log( + `[desktop-update-smoke] Updated app relaunched: version=${updatedMarker.appVersion} windowId=${updatedMarker.windowId}`, + ); + } catch (error) { + const serverOutput = updateServer.output().trim(); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nSmoke temp root: ${tempRoot}${ + serverOutput ? `\nMock update server output:\n${serverOutput}` : "" + }`, + { cause: error }, + ); + } finally { + if (electronApp) { + await electronApp.close().catch(() => undefined); + } + terminateInstalledExecutable(options.command); + await updateServer.stop(); + } +} + +if (process.argv[1] && import.meta.url === NodeURL.pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); + }); +} diff --git a/apps/desktop/src/app/PackagedIntegrity.test.ts b/apps/desktop/src/app/PackagedIntegrity.test.ts new file mode 100644 index 00000000000..e01b0a4e481 --- /dev/null +++ b/apps/desktop/src/app/PackagedIntegrity.test.ts @@ -0,0 +1,101 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests create incomplete packaged-install fixtures directly. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; + +import { + checkPackagedIntegrity, + formatPackagedIntegrityFailure, + PACKAGED_INTEGRITY_MANIFEST_FILE_NAME, + resolveUnpackedRoot, +} from "./PackagedIntegrity.ts"; + +function makeTempInstall() { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-integrity-")); + const resourcesPath = NodePath.join(root, "resources"); + const appPath = NodePath.join(resourcesPath, "app.asar"); + const unpackedRoot = `${appPath}.unpacked`; + const manifestPath = NodePath.join(resourcesPath, PACKAGED_INTEGRITY_MANIFEST_FILE_NAME); + NodeFS.mkdirSync(unpackedRoot, { recursive: true }); + NodeFS.mkdirSync(resourcesPath, { recursive: true }); + return { root, resourcesPath, appPath, unpackedRoot, manifestPath }; +} + +describe("PackagedIntegrity", () => { + it("resolves the app.asar.unpacked sidecar beside app.asar", () => { + assert.equal( + resolveUnpackedRoot( + "C:\\Users\\adam\\AppData\\Local\\Programs\\T3\\resources\\app.asar", + "ignored", + ), + "C:\\Users\\adam\\AppData\\Local\\Programs\\T3\\resources\\app.asar.unpacked", + ); + }); + + it("passes when every manifest file exists", () => { + const install = makeTempInstall(); + NodeFS.mkdirSync(NodePath.join(install.unpackedRoot, "node_modules/effect"), { + recursive: true, + }); + NodeFS.mkdirSync(NodePath.join(install.unpackedRoot, "apps/server/dist"), { + recursive: true, + }); + NodeFS.writeFileSync(NodePath.join(install.unpackedRoot, "apps/server/dist/bin.mjs"), ""); + NodeFS.writeFileSync( + NodePath.join(install.unpackedRoot, "node_modules/effect/package.json"), + "{}", + ); + NodeFS.writeFileSync( + install.manifestPath, + JSON.stringify({ + version: 1, + requiredFiles: ["apps/server/dist/bin.mjs", "node_modules/effect/package.json"], + }), + ); + + const result = checkPackagedIntegrity({ + appPath: install.appPath, + resourcesPath: install.resourcesPath, + manifestPath: install.manifestPath, + isPackaged: true, + }); + + assert.isTrue(result.ok); + }); + + it("reports missing unpacked files before runtime imports crash", () => { + const install = makeTempInstall(); + NodeFS.mkdirSync(NodePath.join(install.unpackedRoot, "apps/server/dist"), { + recursive: true, + }); + NodeFS.writeFileSync(NodePath.join(install.unpackedRoot, "apps/server/dist/bin.mjs"), ""); + NodeFS.writeFileSync( + install.manifestPath, + JSON.stringify({ + version: 1, + requiredFiles: ["apps/server/dist/bin.mjs", "node_modules/effect/index.js"], + }), + ); + + const result = checkPackagedIntegrity({ + appPath: install.appPath, + resourcesPath: install.resourcesPath, + manifestPath: install.manifestPath, + isPackaged: true, + }); + + assert.isFalse(result.ok); + if (result.ok) return; + assert.deepEqual(result.failure.missingFiles, ["node_modules/effect/index.js"]); + assert.include( + formatPackagedIntegrityFailure({ + failure: result.failure, + appVersion: "1.2.3", + updateChannel: "nightly", + }), + "Installation is incomplete", + ); + }); +}); diff --git a/apps/desktop/src/app/PackagedIntegrity.ts b/apps/desktop/src/app/PackagedIntegrity.ts new file mode 100644 index 00000000000..066f94349e9 --- /dev/null +++ b/apps/desktop/src/app/PackagedIntegrity.ts @@ -0,0 +1,142 @@ +// @effect-diagnostics nodeBuiltinImport:off - Packaged startup integrity runs synchronously before the Effect app runtime exists. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +export const PACKAGED_INTEGRITY_MANIFEST_FILE_NAME = "packaged-integrity-manifest.json"; +export const REINSTALL_URL = "https://github.com/agriffiths-bots/t3code/releases/latest"; + +export interface PackagedIntegrityManifest { + readonly version: 1; + readonly requiredFiles: readonly string[]; + readonly unpackedPackages?: readonly string[]; +} + +export interface PackagedIntegrityCheckInput { + readonly appPath: string; + readonly resourcesPath: string; + readonly manifestPath: string; + readonly isPackaged: boolean; +} + +export interface PackagedIntegrityFailure { + readonly kind: "manifest-missing" | "manifest-invalid" | "files-missing"; + readonly appPath: string; + readonly unpackedRoot: string; + readonly manifestPath: string; + readonly missingFiles: readonly string[]; + readonly detail: string; +} + +export interface PackagedIntegritySuccess { + readonly ok: true; + readonly skipped: boolean; + readonly unpackedRoot: string; +} + +export type PackagedIntegrityResult = + | PackagedIntegritySuccess + | { + readonly ok: false; + readonly failure: PackagedIntegrityFailure; + }; + +export function resolveUnpackedRoot(appPath: string, resourcesPath: string): string { + return appPath.endsWith("app.asar") + ? `${appPath}.unpacked` + : NodePath.join(resourcesPath, "app.asar.unpacked"); +} + +function decodePackagedIntegrityManifest(raw: string): PackagedIntegrityManifest { + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + !Array.isArray(parsed.requiredFiles) || + parsed.requiredFiles.some((entry) => typeof entry !== "string" || entry.length === 0) + ) { + throw new Error("manifest does not match version 1 packaged integrity schema"); + } + return { + version: 1, + requiredFiles: parsed.requiredFiles, + ...(Array.isArray(parsed.unpackedPackages) + ? { unpackedPackages: parsed.unpackedPackages.filter((entry) => typeof entry === "string") } + : {}), + }; +} + +export function checkPackagedIntegrity( + input: PackagedIntegrityCheckInput, +): PackagedIntegrityResult { + const unpackedRoot = resolveUnpackedRoot(input.appPath, input.resourcesPath); + if (!input.isPackaged) { + return { ok: true, skipped: true, unpackedRoot }; + } + + let manifest: PackagedIntegrityManifest; + try { + manifest = decodePackagedIntegrityManifest(NodeFS.readFileSync(input.manifestPath, "utf8")); + } catch (cause) { + return { + ok: false, + failure: { + kind: NodeFS.existsSync(input.manifestPath) ? "manifest-invalid" : "manifest-missing", + appPath: input.appPath, + unpackedRoot, + manifestPath: input.manifestPath, + missingFiles: [], + detail: cause instanceof Error ? cause.message : String(cause), + }, + }; + } + + const missingFiles = manifest.requiredFiles.filter( + (relativePath) => !NodeFS.existsSync(NodePath.join(unpackedRoot, relativePath)), + ); + if (missingFiles.length === 0) { + return { ok: true, skipped: false, unpackedRoot }; + } + + return { + ok: false, + failure: { + kind: "files-missing", + appPath: input.appPath, + unpackedRoot, + manifestPath: input.manifestPath, + missingFiles, + detail: `${missingFiles.length} required unpacked file(s) are missing.`, + }, + }; +} + +export function formatPackagedIntegrityFailure(input: { + readonly failure: PackagedIntegrityFailure; + readonly appVersion: string; + readonly updateChannel: string; +}): string { + const preview = input.failure.missingFiles.slice(0, 12); + const missingList = + preview.length > 0 + ? `\n\nMissing files:\n${preview.map((file) => `- ${file}`).join("\n")}${ + input.failure.missingFiles.length > preview.length + ? `\n- ...and ${input.failure.missingFiles.length - preview.length} more` + : "" + }` + : ""; + + return [ + "Installation is incomplete. T3 Code could not verify its packaged runtime files.", + "", + `Version: ${input.appVersion}`, + `Update channel: ${input.updateChannel}`, + `Install path: ${input.failure.appPath}`, + `Unpacked runtime path: ${input.failure.unpackedRoot}`, + `Integrity manifest: ${input.failure.manifestPath}`, + `Problem: ${input.failure.detail}`, + missingList, + "", + `Please reinstall T3 Code from ${REINSTALL_URL}.`, + ] + .filter((part) => part.length > 0) + .join("\n"); +} diff --git a/apps/desktop/src/bootstrap.ts b/apps/desktop/src/bootstrap.ts new file mode 100644 index 00000000000..2d09ba59ea8 --- /dev/null +++ b/apps/desktop/src/bootstrap.ts @@ -0,0 +1,40 @@ +import * as NodeModule from "node:module"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - The bootstrap resolves packaged files before the Effect app runtime exists. +import * as NodePath from "node:path"; + +import * as Electron from "electron"; + +import { + checkPackagedIntegrity, + formatPackagedIntegrityFailure, + PACKAGED_INTEGRITY_MANIFEST_FILE_NAME, +} from "./app/PackagedIntegrity.ts"; + +function resolveUpdateChannel(appVersion: string): string { + return /-nightly\.\d{8}\.\d+$/u.test(appVersion) ? "nightly" : "latest"; +} + +function loadMainProcess() { + const runtimeRequire = NodeModule.createRequire(__filename); + runtimeRequire(NodePath.join(__dirname, "main.cjs")); +} + +const integrityResult = checkPackagedIntegrity({ + appPath: Electron.app.getAppPath(), + resourcesPath: process.resourcesPath, + manifestPath: NodePath.join(process.resourcesPath, PACKAGED_INTEGRITY_MANIFEST_FILE_NAME), + isPackaged: Electron.app.isPackaged, +}); + +if (!integrityResult.ok) { + const content = formatPackagedIntegrityFailure({ + failure: integrityResult.failure, + appVersion: Electron.app.getVersion(), + updateChannel: resolveUpdateChannel(Electron.app.getVersion()), + }); + process.stderr.write(`fatal startup error: ${content}\n`); + Electron.dialog.showErrorBox("T3 Code installation is incomplete", content); + Electron.app.exit(1); +} else { + loadMainProcess(); +} diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 0d1388019bf..80ff466d72b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -39,6 +39,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let verifyCount = 0; let allowDowngrade = false; + const disableDifferentialDownloadValues: boolean[] = []; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; @@ -74,7 +75,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), - setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, + setDisableDifferentialDownload: (value) => + Effect.sync(() => { + disableDifferentialDownloadValues.push(value); + }).pipe(Effect.andThen(options.setDisableDifferentialDownload ?? Effect.void)), verifyAvailable: Effect.sync(() => { verifyCount += 1; }), @@ -191,6 +195,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { checkCount: () => checkCount, feedUrls: () => feedUrls, verifyCount: () => verifyCount, + disableDifferentialDownloadValues: () => disableDifferentialDownloadValues, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, @@ -258,11 +263,12 @@ describe("DesktopUpdates", () => { assert.equal(state.enabled, true); assert.equal(state.status, "idle"); assert.deepEqual(harness.feedUrls(), [ - { provider: "generic", url: "http://localhost:4141" }, + { provider: "generic", url: "http://127.0.0.1:4141" }, ]); assert.equal(harness.listenerCount(), 6); assert.equal(harness.checkCount(), 0); assert.equal(harness.verifyCount(), 0); + assert.deepEqual(harness.disableDifferentialDownloadValues(), [true]); yield* TestClock.adjust(Duration.millis(15_000)); assert.equal(harness.checkCount(), 1); @@ -449,6 +455,7 @@ describe("DesktopUpdates", () => { const retry = yield* updates.download; assert.isTrue(retry.accepted); assert.isTrue(retry.completed); + assert.deepEqual(harness.disableDifferentialDownloadValues(), [true, true, true]); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), @@ -476,6 +483,7 @@ describe("DesktopUpdates", () => { assert.equal(failedState.status, "downloaded"); assert.equal(failedState.errorContext, "install"); assert.equal(failedState.message, "Desktop update install action failed unexpectedly."); + assert.isTrue(harness.sentStates.some((state) => state.status === "installing")); const changedState = yield* updates.setChannel("nightly"); assert.equal(changedState.channel, "nightly"); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 9ab4e7e0a09..6058b0ae25f 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -37,12 +37,14 @@ import { reduceDesktopUpdateStateOnDownloadProgress, reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, + reduceDesktopUpdateStateOnInstallStart, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine.ts"; const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; +const MOCK_UPDATE_SERVER_HOST = "127.0.0.1"; const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; @@ -346,7 +348,11 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(updateCheckInFlightRef)) return false; const state = yield* Ref.get(updateStateRef); - if (state.status === "downloading" || state.status === "downloaded") { + if ( + state.status === "downloading" || + state.status === "downloaded" || + state.status === "installing" + ) { yield* logUpdaterInfo("skipping update check while update is active", { reason, status: state.status, @@ -393,9 +399,7 @@ export const make = Effect.gen(function* () { yield* Ref.set(updateDownloadInFlightRef, true); return yield* Effect.gen(function* () { yield* setState(reduceDesktopUpdateStateOnDownloadStart(state)); - yield* electronUpdater.setDisableDifferentialDownload( - isArm64HostRunningIntelBuild(environment.runtimeInfo), - ); + yield* electronUpdater.setDisableDifferentialDownload(true); yield* logUpdaterInfo("downloading update"); yield* electronUpdater.downloadUpdate; return { accepted: true, completed: true }; @@ -456,6 +460,7 @@ export const make = Effect.gen(function* () { yield* Ref.set(desktopState.quitting, true); yield* Ref.set(updateInstallInFlightRef, true); + yield* setState(reduceDesktopUpdateStateOnInstallStart(state)); return yield* Effect.gen(function* () { // Stop every backend in the pool, not just the primary. With @@ -712,7 +717,7 @@ export const make = Effect.gen(function* () { if (config.mockUpdates) { yield* electronUpdater.setFeedURL({ provider: "generic", - url: `http://localhost:${config.mockUpdateServerPort}`, + url: `http://${MOCK_UPDATE_SERVER_HOST}:${config.mockUpdateServerPort}`, } as ElectronUpdater.ElectronUpdaterFeedUrl); } @@ -727,9 +732,7 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setAutoDownload(false); yield* electronUpdater.setAutoInstallOnAppQuit(false); yield* applyAutoUpdaterChannel(settings.updateChannel); - yield* electronUpdater.setDisableDifferentialDownload( - isArm64HostRunningIntelBuild(environment.runtimeInfo), - ); + yield* electronUpdater.setDisableDifferentialDownload(true); if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) { yield* logUpdaterInfo( diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index 4b2a87c7ce0..01233d7f7df 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -9,6 +9,7 @@ import { reduceDesktopUpdateStateOnDownloadProgress, reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, + reduceDesktopUpdateStateOnInstallStart, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine.ts"; @@ -95,6 +96,22 @@ describe("updateMachine", () => { expect(failedInstall.canRetry).toBe(true); }); + it("surfaces an installing state before handing off to the installer", () => { + const installing = reduceDesktopUpdateStateOnInstallStart({ + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), + enabled: true, + status: "downloaded", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + canRetry: true, + }); + + expect(installing.status).toBe("installing"); + expect(installing.message).toBeNull(); + expect(installing.errorContext).toBeNull(); + expect(installing.canRetry).toBe(false); + }); + it("clears stale download state when no update is available", () => { const state = reduceDesktopUpdateStateOnNoUpdate( { diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index b5037225774..ac51591ebd3 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -160,6 +160,18 @@ export function reduceDesktopUpdateStateOnDownloadComplete( }; } +export function reduceDesktopUpdateStateOnInstallStart( + state: DesktopUpdateState, +): DesktopUpdateState { + return { + ...state, + status: "installing", + message: null, + errorContext: null, + canRetry: false, + }; +} + export function reduceDesktopUpdateStateOnInstallFailure( state: DesktopUpdateState, message: string, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 85b25740ba2..7ad9f6f19b2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -38,6 +38,7 @@ const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ const DesktopSmokeReadyMarkerJson = Schema.fromJsonString( Schema.Struct({ status: Schema.Literal("main-window-ready"), + appVersion: Schema.String, windowId: Schema.Number, title: Schema.String, visible: Schema.Boolean, @@ -297,6 +298,7 @@ const writeSmokeReadyMarker = Effect.fn("desktop.window.writeSmokeReadyMarker")( const path = yield* Path.Path; const markerJson = yield* encodeDesktopSmokeReadyMarker({ status: "main-window-ready", + appVersion: environment.appVersion, windowId: window.id, title: environment.displayName, visible: !window.isDestroyed() && window.isVisible(), diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index e8516474b9f..ac8a9857a67 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -70,7 +70,7 @@ export default defineConfig({ sourcemap: true, outExtensions: () => ({ js: ".cjs" }), define: publicConfigDefine, - entry: ["src/main.ts"], + entry: ["src/bootstrap.ts", "src/main.ts"], clean: true, deps: { alwaysBundle: shouldBundleDesktopMainDependency, diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index a05bc33a10f..623801d68e8 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -104,6 +104,20 @@ describe("desktop update button state", () => { expect(isDesktopUpdateButtonDisabled(state)).toBe(true); expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); }); + + it("keeps the update control visible but disabled while installing", () => { + const state: DesktopUpdateState = { + ...baseState, + status: "installing", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + message: null, + }; + + expect(shouldShowDesktopUpdateButton(state)).toBe(true); + expect(isDesktopUpdateButtonDisabled(state)).toBe(true); + expect(getDesktopUpdateButtonTooltip(state)).toContain("Installing update"); + }); }); describe("getDesktopUpdateActionError", () => { @@ -154,6 +168,21 @@ describe("getDesktopUpdateActionError", () => { }; expect(getDesktopUpdateActionError(result)).toBeNull(); }); + + it("ignores non-error progress states for accepted handoffs", () => { + const result: DesktopUpdateActionResult = { + accepted: true, + completed: false, + state: { + ...baseState, + status: "installing", + downloadedVersion: "1.1.0", + message: "Installing update. T3 Code will restart when installation is ready.", + errorContext: null, + }, + }; + expect(getDesktopUpdateActionError(result)).toBeNull(); + }); }); describe("desktop update UI helpers", () => { @@ -162,7 +191,7 @@ describe("desktop update UI helpers", () => { shouldToastDesktopUpdateActionResult({ accepted: true, completed: false, - state: { ...baseState, message: "checksum mismatch" }, + state: { ...baseState, message: "checksum mismatch", errorContext: "download" }, }), ).toBe(true); expect( @@ -256,6 +285,17 @@ describe("canCheckForUpdate", () => { ).toBe(false); }); + it("returns false while installing", () => { + expect( + canCheckForUpdate({ + ...baseState, + status: "installing", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }), + ).toBe(false); + }); + it("returns true when idle", () => { expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 38983c810b5..bf4333cfea5 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -26,6 +26,9 @@ export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): if (state.status === "downloading") { return true; } + if (state.status === "installing") { + return true; + } return resolveDesktopUpdateButtonAction(state) !== "none"; } @@ -34,7 +37,7 @@ export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | nul } export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean { - return state?.status === "downloading"; + return state?.status === "downloading" || state?.status === "installing"; } export function getArm64IntelBuildWarningDescription(state: DesktopUpdateState): string { @@ -64,6 +67,9 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string if (state.status === "downloaded") { return `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`; } + if (state.status === "installing") { + return "Installing update. T3 Code will restart shortly."; + } if (state.status === "error") { if (state.errorContext === "download" && state.availableVersion) { return `Download failed for ${state.availableVersion}. Click to retry.`; @@ -85,6 +91,9 @@ export function getDesktopUpdateInstallConfirmationMessage( export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { if (!result.accepted || result.completed) return null; + if (result.state.errorContext !== "download" && result.state.errorContext !== "install") { + return null; + } if (typeof result.state.message !== "string") return null; const message = result.state.message.trim(); return message.length > 0 ? message : null; @@ -105,6 +114,7 @@ export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { state.status !== "checking" && state.status !== "downloading" && state.status !== "downloaded" && + state.status !== "installing" && state.status !== "disabled" ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 12d3858a754..d06ba155dbe 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -276,6 +276,7 @@ function AboutVersionSection() { const statusLabel: Record = { checking: "Checking…", downloading: "Downloading…", + installing: "Installing…", "up-to-date": "Up to Date", }; const buttonLabel = diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index c3ac56d1092..ac7a03eeb26 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -127,7 +127,12 @@ export function SidebarUpdatePill() { className="update-main relative flex h-full flex-1 items-center gap-2 px-2 enabled:cursor-pointer" onClick={handleAction} > - {action === "install" ? ( + {state?.status === "installing" ? ( + <> + + Installing update + + ) : action === "install" ? ( <> Restart to update diff --git a/docs/operations/release.md b/docs/operations/release.md index dee10fb6653..d54807a4919 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -175,7 +175,7 @@ One-time Vercel dashboard setup: - Required release assets for updater: - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads) - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases - - `*.blockmap` files (used for differential downloads) + - `*.blockmap` files (published for compatibility; T3 Code disables runtime differential downloads and installs full verified update payloads) - macOS metadata note: - `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. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8a05b15365b..4b1f7e08a97 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -157,6 +157,7 @@ export type DesktopUpdateStatus = | "available" | "downloading" | "downloaded" + | "installing" | "error"; export type DesktopRuntimeArch = "arm64" | "x64" | "other"; @@ -172,6 +173,7 @@ export const DesktopUpdateStatusSchema = Schema.Literals([ "available", "downloading", "downloaded", + "installing", "error", ]); export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index e86f59cf8c3..c38cd8b1002 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -717,8 +717,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); it("falls back to the default mock update port when the configured port is blank", () => { - assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000"); - assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); + assert.equal(resolveMockUpdateServerUrl(undefined), "http://127.0.0.1:3000"); + assert.equal(resolveMockUpdateServerUrl(4123), "http://127.0.0.1:4123"); }); it.effect("normalizes mock update server ports from env-style strings", () => diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 3e13fed10bc..c3fa6af9a8a 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -593,6 +593,7 @@ export const DESKTOP_PACKAGE_BUILD_ENV = { const FFI_RS_VERSION = "1.3.2"; export const DESKTOP_AFTER_PACK_HOOK_STAGE_PATH = "desktop-after-pack-prune.mjs"; export const DESKTOP_ASAR_UNPACK_BASE = ["apps/server/dist/**"] as const; +export const MOCK_UPDATE_SERVER_HOST = "127.0.0.1"; export const DESKTOP_UNPACKED_FILE_LIMIT = 250; export const DESKTOP_NATIVE_ASAR_UNPACK_PACKAGE_PATTERNS = [ "@anthropic-ai/claude-agent-sdk-*", @@ -1564,7 +1565,7 @@ export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIcon } export function resolveMockUpdateServerUrl(mockUpdateServerPort: number | undefined): string { - return `http://localhost:${mockUpdateServerPort ?? 3000}`; + return `http://${MOCK_UPDATE_SERVER_HOST}:${mockUpdateServerPort ?? 3000}`; } export function resolveDesktopProductName(version: string): string { @@ -1818,9 +1819,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const iconAssets = resolveDesktopBuildIconAssets(appVersion); const commitHash = yield* resolveGitCommitHash(repoRoot); const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; - const stageRoot = yield* mkdir({ + const createdStageRoot = yield* mkdir({ prefix: `t3code-desktop-${options.platform}-stage-`, }); + const stageRoot = yield* fs + .realPath(createdStageRoot) + .pipe(Effect.orElseSucceed(() => createdStageRoot)); const stageAppDir = path.join(stageRoot, "app"); const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources"); @@ -1972,7 +1976,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( packageManager: rootPackageJson.packageManager, description: "T3 Code desktop build", author: "T3 Tools", - main: "apps/desktop/dist-electron/main.cjs", + main: "apps/desktop/dist-electron/bootstrap.cjs", build: yield* createBuildConfig( options.platform, options.target, diff --git a/scripts/desktop-after-pack-prune.mjs b/scripts/desktop-after-pack-prune.mjs index 719f406eb19..e87adb59a01 100644 --- a/scripts/desktop-after-pack-prune.mjs +++ b/scripts/desktop-after-pack-prune.mjs @@ -1,6 +1,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +export const PACKAGED_INTEGRITY_MANIFEST_FILE_NAME = "packaged-integrity-manifest.json"; const RUNTIME_NODE_PTY_BUILD_FILE = NodePath.normalize("Release/pty.node"); const NODE_PTY_KEEP_PREBUILD_FILE_NAMES = new Set([ "pty.node", @@ -86,6 +87,71 @@ async function removeEmptyDirectories(path) { } } +function toPosixPath(path) { + return path.split(NodePath.sep).join("/"); +} + +async function collectRelativeFiles(input) { + const { root, current, logicalRoot, files, visited } = input; + const realCurrent = await NodeFSP.realpath(current).catch(() => current); + const visitedKey = `${realCurrent}\0${logicalRoot}`; + if (visited.has(visitedKey)) { + return; + } + visited.add(visitedKey); + + for (const entry of await listEntries(current)) { + const entryPath = NodePath.join(current, entry.name); + const logicalPath = logicalRoot ? `${logicalRoot}/${entry.name}` : entry.name; + const targetStat = entry.isSymbolicLink() + ? await NodeFSP.stat(entryPath).catch(() => null) + : null; + + if (entry.isDirectory() || targetStat?.isDirectory()) { + await collectRelativeFiles({ + root, + current: entryPath, + logicalRoot: logicalPath, + files, + visited, + }); + continue; + } + + if (entry.isFile() || targetStat?.isFile()) { + files.add(toPosixPath(NodePath.relative(root, entryPath))); + } + } +} + +export async function createPackagedIntegrityManifest(unpackedRoot) { + const requiredFiles = new Set(); + await collectRelativeFiles({ + root: unpackedRoot, + current: unpackedRoot, + logicalRoot: "", + files: requiredFiles, + visited: new Set(), + }); + + return { + version: 1, + generatedAt: new Date().toISOString(), + requiredFiles: [...requiredFiles].sort(), + }; +} + +async function writePackagedIntegrityManifest(resourcesDir, unpackedRoot) { + const manifest = await createPackagedIntegrityManifest(unpackedRoot); + await NodeFSP.writeFile( + NodePath.join(resourcesDir, PACKAGED_INTEGRITY_MANIFEST_FILE_NAME), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + console.log( + `[desktop-after-pack-prune] wrote ${manifest.requiredFiles.length} packaged integrity entries`, + ); +} + async function pruneNodePtyLibDirectory(directory) { for (const entry of await listEntries(directory)) { const entryPath = NodePath.join(directory, entry.name); @@ -310,11 +376,15 @@ function resolveAppAsarUnpackedRoots(appOutDir) { export default async function afterPack(context) { for (const root of resolveAppAsarUnpackedRoots(context.appOutDir)) { + if (!(await pathExists(root))) { + continue; + } await pruneNativeSidecars(root, context.electronPlatformName, context.arch); await pruneNodePty( NodePath.join(root, "node_modules", "node-pty"), context.electronPlatformName, context.arch, ); + await writePackagedIntegrityManifest(NodePath.dirname(root), root); } } diff --git a/scripts/desktop-after-pack-prune.test.mjs b/scripts/desktop-after-pack-prune.test.mjs index 44a473d1ebe..eaf384609fb 100644 --- a/scripts/desktop-after-pack-prune.test.mjs +++ b/scripts/desktop-after-pack-prune.test.mjs @@ -3,7 +3,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; -import afterPack from "./desktop-after-pack-prune.mjs"; +import afterPack, { PACKAGED_INTEGRITY_MANIFEST_FILE_NAME } from "./desktop-after-pack-prune.mjs"; async function touch(path) { await NodeFSP.mkdir(NodePath.dirname(path), { recursive: true }); @@ -106,5 +106,21 @@ describe("desktop-after-pack-prune", () => { await expect( exists(NodePath.join(root, "node-pty/third_party/conpty/1.23/win10-arm64")), ).resolves.toBe(false); + + const manifestPath = NodePath.join(tempDir, "resources", PACKAGED_INTEGRITY_MANIFEST_FILE_NAME); + await expect(exists(manifestPath)).resolves.toBe(true); + const manifest = JSON.parse(await NodeFSP.readFile(manifestPath, "utf8")); + expect(manifest.version).toBe(1); + expect(manifest.requiredFiles).toContain("node_modules/node-pty/package.json"); + expect(manifest.requiredFiles).toContain("node_modules/node-pty/prebuilds/win32-x64/pty.node"); + expect(manifest.requiredFiles).toContain( + "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff_c.so", + ); + expect(manifest.requiredFiles).not.toContain( + "node_modules/node-pty/prebuilds/win32-arm64/pty.node", + ); + expect(manifest.requiredFiles).not.toContain( + "node_modules/@ff-labs/fff-bin-linux-x64-musl/libfff_c.so", + ); }); }); diff --git a/scripts/desktop-launch-smoke.mjs b/scripts/desktop-launch-smoke.mjs index 2a26696cf13..906074d3f48 100644 --- a/scripts/desktop-launch-smoke.mjs +++ b/scripts/desktop-launch-smoke.mjs @@ -13,7 +13,8 @@ const DEFAULT_TIMEOUT_MS = 90_000; const DEFAULT_STABILITY_MS = 5_000; const DESCRIPTOR_PATH = "/.well-known/t3/environment"; const READY_MARKER_FILE = "main-window-ready.json"; -const MAX_CAPTURED_OUTPUT_BYTES = 256 * 1024; +const MAX_CAPTURED_OUTPUT_BYTES = 2 * 1024 * 1024; +const FATAL_OUTPUT_SNAPSHOT_BYTES = 16 * 1024; const FATAL_OUTPUT_PATTERNS = [ /ERR_MODULE_NOT_FOUND/i, /\bMODULE_NOT_FOUND\b/i, @@ -266,15 +267,97 @@ export async function readReadyMarker(filePath) { } } -function appendOutput(current, streamName, chunk) { - const next = `${current}${streamName}: ${chunk.toString()}`; - return next.length > MAX_CAPTURED_OUTPUT_BYTES - ? next.slice(next.length - MAX_CAPTURED_OUTPUT_BYTES) - : next; +function formatOutputEntry(streamName, chunk) { + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + return `${streamName}: ${text}`; +} + +export function appendOutput(current, streamName, chunk, maxBytes = MAX_CAPTURED_OUTPUT_BYTES) { + const entry = formatOutputEntry(streamName, chunk); + const next = `${current}${current ? "\n" : ""}${entry}`; + return next.length > maxBytes ? next.slice(next.length - maxBytes) : next; +} + +function findFatalOutputMatch(output) { + for (const pattern of FATAL_OUTPUT_PATTERNS) { + pattern.lastIndex = 0; + const match = pattern.exec(output); + if (match?.index !== undefined) { + return { pattern, index: match.index, length: match[0]?.length ?? 0 }; + } + } + return undefined; +} + +export function captureFatalOutput(output, maxBytes = FATAL_OUTPUT_SNAPSHOT_BYTES) { + const match = findFatalOutputMatch(output); + if (!match) { + return undefined; + } + if (output.length <= maxBytes) { + return output; + } + + const prefix = "[output truncated before fatal]\n"; + const suffix = "\n[output truncated after fatal]"; + const markerBytes = prefix.length + suffix.length; + const windowBytes = Math.max(1, maxBytes - markerBytes); + const contextBeforeBytes = Math.floor(windowBytes / 4); + const contextAfterBytes = windowBytes - contextBeforeBytes; + const preferredStart = Math.max(0, match.index - contextBeforeBytes); + const preferredEnd = Math.min( + output.length, + match.index + Math.max(match.length, 1) + contextAfterBytes, + ); + const lineStart = output.lastIndexOf("\n", match.index); + const lineEnd = output.indexOf("\n", match.index + Math.max(match.length, 1)); + const start = lineStart >= preferredStart ? lineStart + 1 : preferredStart; + const end = lineEnd !== -1 && lineEnd <= preferredEnd ? lineEnd : preferredEnd; + + const formatSnapshot = (sliceStart, sliceEnd) => { + const truncatedPrefix = sliceStart > 0 ? prefix : ""; + const truncatedSuffix = sliceEnd < output.length ? suffix : ""; + return `${truncatedPrefix}${output.slice(sliceStart, sliceEnd)}${truncatedSuffix}`; + }; + + const snapshot = formatSnapshot(start, end); + if (snapshot.length <= maxBytes) { + return snapshot; + } + + const truncatedPrefix = start > 0 ? prefix : ""; + const truncatedSuffix = end < output.length ? suffix : ""; + const bodyBudget = maxBytes - truncatedPrefix.length - truncatedSuffix.length; + if (bodyBudget <= 0) { + return output.slice(match.index, Math.min(output.length, match.index + maxBytes)); + } + + const matchStart = Math.min(Math.max(match.index, start), end); + const matchEnd = Math.min(end, matchStart + Math.max(match.length, 1)); + const matchBytes = matchEnd - matchStart; + const beforeBudget = Math.min( + matchStart - start, + Math.max(0, Math.floor((bodyBudget - matchBytes) / 4)), + ); + let clampedStart = matchStart - beforeBudget; + let clampedEnd = Math.min(end, clampedStart + bodyBudget); + if (clampedEnd <= matchEnd) { + clampedEnd = Math.min(end, matchEnd); + clampedStart = Math.max(start, clampedEnd - bodyBudget); + } + + return formatSnapshot(clampedStart, clampedEnd); } function outputHasFatalPattern(output) { - return FATAL_OUTPUT_PATTERNS.find((pattern) => pattern.test(output)); + return findFatalOutputMatch(output)?.pattern; +} + +function appendOutputEntry(current, entry) { + const next = `${current}${current ? "\n" : ""}${entry}`; + return next.length > MAX_CAPTURED_OUTPUT_BYTES + ? next.slice(next.length - MAX_CAPTURED_OUTPUT_BYTES) + : next; } function signalProcessTree(child, signal) { @@ -336,9 +419,15 @@ async function collectDiagnostics(tempRoot, output) { async function assertHealthy(child, output, serverChildLogPath) { const serverChildLog = await readOptionalText(serverChildLogPath); - const fatalPattern = outputHasFatalPattern(`${output()}\n${serverChildLog}`); + const diagnosticOutput = `${output()}\n${serverChildLog}`; + const fatalPattern = outputHasFatalPattern(diagnosticOutput); if (fatalPattern) { - throw new Error(`Desktop launch emitted fatal pattern ${fatalPattern}`); + const fatalSnapshot = captureFatalOutput(diagnosticOutput); + throw new Error( + `Desktop launch emitted fatal pattern ${fatalPattern}${ + fatalSnapshot ? `\nFatal output snapshot:\n${fatalSnapshot}` : "" + }`, + ); } if (child.exitCode !== null || child.signalCode !== null) { throw new Error( @@ -399,12 +488,27 @@ async function main() { }); let output = ""; + let fatalOutput = ""; let exit = null; + const recordOutput = (streamName, chunk) => { + const entry = formatOutputEntry(streamName, chunk); + if (!fatalOutput) { + const candidate = captureFatalOutput(`${output}${output ? "\n" : ""}${entry}`); + if (candidate) { + fatalOutput = candidate; + } + } + output = appendOutputEntry(output, entry); + }; + const diagnosticOutput = () => + fatalOutput + ? `Fatal output snapshot:\n${fatalOutput}\n\nLatest process output:\n${output}` + : output; child.stdout.on("data", (chunk) => { - output = appendOutput(output, "stdout", chunk); + recordOutput("stdout", chunk); }); child.stderr.on("data", (chunk) => { - output = appendOutput(output, "stderr", chunk); + recordOutput("stderr", chunk); }); child.once("exit", (code, signal) => { exit = { code, signal }; @@ -415,7 +519,7 @@ async function main() { let loggedBackendReady = false; try { while (Date.now() < deadline) { - await assertHealthy(child, () => output, serverChildLogPath); + await assertHealthy(child, diagnosticOutput, serverChildLogPath); if (!backendDescriptor) { try { @@ -457,7 +561,7 @@ async function main() { const stableUntil = Math.min(deadline, Date.now() + options.stabilityMs); while (Date.now() < stableUntil) { await NodeTimersPromises.setTimeout(500); - await assertHealthy(child, () => output, serverChildLogPath); + await assertHealthy(child, diagnosticOutput, serverChildLogPath); } return; } @@ -466,7 +570,7 @@ async function main() { `Timed out after ${options.timeoutMs}ms waiting for desktop backend and main-window readiness`, ); } catch (error) { - const diagnostics = await collectDiagnostics(tempRoot, output); + const diagnostics = await collectDiagnostics(tempRoot, diagnosticOutput()); throw new Error(`${error instanceof Error ? error.message : String(error)}${diagnostics}`, { cause: error, }); diff --git a/scripts/desktop-launch-smoke.test.mjs b/scripts/desktop-launch-smoke.test.mjs index bce50be68ac..3294d802ad1 100644 --- a/scripts/desktop-launch-smoke.test.mjs +++ b/scripts/desktop-launch-smoke.test.mjs @@ -3,7 +3,12 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import { readReadyMarker, validateReadyMarker } from "./desktop-launch-smoke.mjs"; +import { + appendOutput, + captureFatalOutput, + readReadyMarker, + validateReadyMarker, +} from "./desktop-launch-smoke.mjs"; describe("desktop-launch-smoke ready marker validation", () => { it("accepts a loaded app window even when the CI runner reports it hidden", () => { @@ -63,3 +68,61 @@ describe("desktop-launch-smoke ready marker validation", () => { await expect(readReadyMarker(markerPath)).resolves.toBeUndefined(); }); }); + +describe("desktop-launch-smoke output diagnostics", () => { + it("keeps stream entries separated while appending output", () => { + expect(appendOutput("stdout: first", "stderr", "second", 1_000)).toBe( + "stdout: first\nstderr: second", + ); + }); + + it("captures a bounded fatal snapshot before later output can displace it", () => { + const snapshot = captureFatalOutput( + `stdout: booting\nstderr: fatal startup error: missing runtime file\n${"tail\n".repeat(200)}`, + 220, + ); + + expect(snapshot).toContain("fatal startup error"); + expect(snapshot).toContain("[output truncated after fatal]"); + expect(snapshot.length).toBeLessThanOrEqual(220); + }); + + it("focuses a fatal snapshot on the matching output line", () => { + const snapshot = captureFatalOutput( + `${"stdout: /tmp/appimage_extracted/path/node_modules/react-native/File.cpp\n".repeat( + 200, + )}stderr: UnhandledPromiseRejection: missing packaged module\n${"tail\n".repeat(200)}`, + 260, + ); + + expect(snapshot).toContain("UnhandledPromiseRejection"); + expect(snapshot).toContain("missing packaged module"); + expect(snapshot).not.toContain("react-native/File.cpp"); + expect(snapshot.length).toBeLessThanOrEqual(260); + }); + + it("keeps single-line fatal snapshots inside the requested cap", () => { + const snapshot = captureFatalOutput( + `${"prefix ".repeat(80)}ERR_MODULE_NOT_FOUND${" tail".repeat(120)}`, + 220, + ); + + expect(snapshot).toContain("ERR_MODULE_NOT_FOUND"); + expect(snapshot.length).toBeLessThanOrEqual(220); + }); + + it("keeps context around a fatal pattern after earlier output is truncated", () => { + const snapshot = captureFatalOutput( + `${"prefix\n".repeat(200)}stderr: ERR_MODULE_NOT_FOUND: missing package\nstdout: done`, + 220, + ); + + expect(snapshot).toContain("[output truncated before fatal]"); + expect(snapshot).toContain("ERR_MODULE_NOT_FOUND"); + expect(snapshot.length).toBeLessThanOrEqual(220); + }); + + it("does not create a fatal snapshot for ordinary output", () => { + expect(captureFatalOutput("stdout: ready")).toBeUndefined(); + }); +}); diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts index de50a6c0459..78e8c356279 100644 --- a/scripts/mock-update-server.ts +++ b/scripts/mock-update-server.ts @@ -140,7 +140,7 @@ const makeMockUpdateServerLayer = (config: MockUpdateServerConfig) => HttpRouter.serve(makeMockUpdateRouteLayer(config.rootRealPath)).pipe( Layer.provideMerge( NodeHttpServer.layer(NodeHttp.createServer, { - host: "localhost", + host: "127.0.0.1", port: config.port, }), ),