diff --git a/.gitignore b/.gitignore index 3e8d287755..c57086ef00 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ packages/*/dist build/ .logs/ release/ +release-mock/ .t3 .idea/ apps/web/.playwright diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c3dba6016e..f60e2ee266 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -275,10 +275,12 @@ let updatePollTimer: ReturnType | null = null; let updateStartupTimer: ReturnType | null = null; let updateCheckInFlight = false; let updateDownloadInFlight = false; +let updateInstallInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { + if (updateInstallInFlight) return "install"; if (updateDownloadInFlight) return "download"; if (updateCheckInFlight) return "check"; return updateState.errorContext; @@ -795,13 +797,18 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed } isQuitting = true; + updateInstallInFlight = true; clearUpdatePollTimer(); try { await stopBackendAndWaitForExit(); + // `quitAndInstall()` only starts the handoff to the updater. The actual + // install may still fail asynchronously, so keep the action incomplete + // until we either quit or receive an updater error. autoUpdater.quitAndInstall(); - return { accepted: true, completed: true }; + return { accepted: true, completed: false }; } catch (error: unknown) { const message = formatErrorMessage(error); + updateInstallInFlight = false; isQuitting = false; setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); console.error(`[desktop-updater] Failed to install update: ${message}`); @@ -838,6 +845,13 @@ function configureAutoUpdater(): void { } } + if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { + autoUpdater.setFeedURL({ + provider: "generic", + url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, + }); + } + autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; // Keep alpha branding, but force all installs onto the stable update track. @@ -874,6 +888,13 @@ function configureAutoUpdater(): void { }); autoUpdater.on("error", (error) => { const message = formatErrorMessage(error); + if (updateInstallInFlight) { + updateInstallInFlight = false; + isQuitting = false; + setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); + console.error(`[desktop-updater] Updater error: ${message}`); + return; + } if (!updateCheckInFlight && !updateDownloadInFlight) { setUpdateState({ status: "error", @@ -1334,6 +1355,7 @@ async function bootstrap(): Promise { app.on("before-quit", () => { isQuitting = true; + updateInstallInFlight = false; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); stopBackend(); diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 984eebd6b1..340b9a0164 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -145,19 +145,26 @@ describe("getDesktopUpdateActionError", () => { }); describe("desktop update UI helpers", () => { - it("toasts only for accepted incomplete actions", () => { + it("toasts only for actionable updater errors", () => { expect( shouldToastDesktopUpdateActionResult({ accepted: true, completed: false, - state: baseState, + state: { ...baseState, message: "checksum mismatch" }, }), ).toBe(true); + expect( + shouldToastDesktopUpdateActionResult({ + accepted: true, + completed: false, + state: { ...baseState, message: null }, + }), + ).toBe(false); expect( shouldToastDesktopUpdateActionResult({ accepted: true, completed: true, - state: baseState, + state: { ...baseState, message: "checksum mismatch" }, }), ).toBe(false); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index faf30883cc..5c9c303026 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -87,7 +87,7 @@ export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): } export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateActionResult): boolean { - return result.accepted && !result.completed; + return getDesktopUpdateActionError(result) !== null; } export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | null): boolean { diff --git a/package.json b/package.json index 02e71cf097..f5b2f33329 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "start": "turbo run start --filter=t3", "start:desktop": "turbo run start --filter=@t3tools/desktop", "start:marketing": "turbo run preview --filter=@t3tools/marketing", + "start:mock-update-server": "bun run scripts/mock-update-server.ts", "build": "turbo run build", "build:marketing": "turbo run build --filter=@t3tools/marketing", "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 0b875721fd..4f1886c4ad 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -74,6 +74,8 @@ interface BuildCliInput { readonly keepStage: Option.Option; readonly signed: Option.Option; readonly verbose: Option.Option; + readonly mockUpdates: Option.Option; + readonly mockUpdateServerPort: Option.Option; } function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { @@ -162,6 +164,8 @@ interface ResolvedBuildOptions { readonly keepStage: boolean; readonly signed: boolean; readonly verbose: boolean; + readonly mockUpdates: boolean; + readonly mockUpdateServerPort: string | undefined; } interface StagePackageJson { @@ -204,6 +208,8 @@ const BuildEnvConfig = Config.all({ keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(false)), signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(false)), verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), + mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), + mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), }); const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => @@ -231,13 +237,26 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform)); const version = mergeOptions(input.buildVersion, env.version, undefined); - const outputDir = path.resolve(repoRoot, mergeOptions(input.outputDir, env.outputDir, "release")); + const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) + ? "release-mock" + : "release"; + const outputDir = path.resolve( + repoRoot, + mergeOptions(input.outputDir, env.outputDir, releaseDir), + ); const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild); const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage); const signed = resolveBooleanFlag(input.signed, env.signed); const verbose = resolveBooleanFlag(input.verbose, env.verbose); + const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates); + const mockUpdateServerPort = mergeOptions( + input.mockUpdateServerPort, + env.mockUpdateServerPort, + undefined, + ); + return { platform, target, @@ -248,6 +267,8 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B keepStage, signed, verbose, + mockUpdates, + mockUpdateServerPort, } satisfies ResolvedBuildOptions; }); @@ -447,6 +468,8 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( target: string, productName: string, signed: boolean, + mockUpdates: boolean, + mockUpdateServerPort: string | undefined, ) { const buildConfig: Record = { appId: "com.t3tools.t3code", @@ -459,6 +482,13 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( const publishConfig = resolveGitHubPublishConfig(); if (publishConfig) { buildConfig.publish = [publishConfig]; + } else if (mockUpdates) { + buildConfig.publish = [ + { + provider: "generic", + url: `http://localhost:${mockUpdateServerPort ?? 3000}`, + }, + ]; } if (platform === "mac") { @@ -631,6 +661,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.target, desktopPackageJson.productName ?? "T3 Code", options.signed, + options.mockUpdates, + options.mockUpdateServerPort, ), dependencies: { ...resolvedServerDependencies, @@ -769,6 +801,14 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."), Flag.optional, ), + mockUpdates: Flag.boolean("mock-updates").pipe( + Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."), + Flag.optional, + ), + mockUpdateServerPort: Flag.string("mock-update-server-port").pipe( + Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), + Flag.optional, + ), }).pipe( Command.withDescription("Build a desktop artifact for T3 Code."), Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts new file mode 100644 index 0000000000..bb48413472 --- /dev/null +++ b/scripts/mock-update-server.ts @@ -0,0 +1,45 @@ +import { resolve, relative, dirname } from "node:path"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const port = Number(process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000); +const root = + process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT ?? + resolve(dirname(fileURLToPath(import.meta.url)), "..", "release-mock"); + +const mockServerLog = (level: "info" | "warn" | "error" = "info", message: string) => { + console[level](`[mock-update-server] ${message}`); +}; + +function isWithinRoot(filePath: string): boolean { + try { + return !relative(realpathSync(root), realpathSync(filePath)).startsWith("."); + } catch (error) { + mockServerLog("error", `Error checking if file is within root: ${error}`); + return false; + } +} + +Bun.serve({ + port, + hostname: "localhost", + fetch: async (request) => { + const url = new URL(request.url); + const path = url.pathname; + mockServerLog("info", `Request received for path: ${path}`); + const filePath = resolve(root, `.${path}`); + if (!isWithinRoot(filePath)) { + mockServerLog("warn", `Attempted to access file outside of root: ${filePath}`); + return new Response("Not Found", { status: 404 }); + } + const file = Bun.file(filePath); + if (!(await file.exists())) { + mockServerLog("warn", `Attempted to access non-existent file: ${filePath}`); + return new Response("Not Found", { status: 404 }); + } + mockServerLog("info", `Serving file: ${filePath}`); + return new Response(file.stream()); + }, +}); + +mockServerLog("info", `running on http://localhost:${port}`);