authorgravatar for noah@wts.devNoah Gregory <noah@wts.dev> 2026-03-28 18:44:13-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-28 15:44:13-07:00
logf4617e7b19830111d88b1d914b3ca4e1396912a3
tree27f554f53d0e22d84ac46a56e36b698a1731145a
parentc426452239e06417ec8e231b7883f9c2292df63c
signature Signed by PGP key B5690EEEBB952194

feat: add mock update server (#1180)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

7 files changed, 121 insertions(+), 6 deletions(-)

.gitignore+1
......@@ -12,6 +12,7 @@ packages/*/dist
1212build/
1313.logs/
1414release/
15release-mock/
1516.t3
1617.idea/
1718apps/web/.playwright
apps/desktop/src/main.ts+23-1
......@@ -287,10 +287,12 @@ let updatePollTimer: ReturnType<typeof setInterval> | null = null;
287287let updateStartupTimer: ReturnType<typeof setTimeout> | null = null;
288288let updateCheckInFlight = false;
289289let updateDownloadInFlight = false;
290let updateInstallInFlight = false;
290291let updaterConfigured = false;
291292let updateState: DesktopUpdateState = initialUpdateState();
292293
293294function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
295 if (updateInstallInFlight) return "install";
294296 if (updateDownloadInFlight) return "download";
295297 if (updateCheckInFlight) return "check";
296298 return updateState.errorContext;
......@@ -807,6 +809,7 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed
807809 }
808810
809811 isQuitting = true;
812 updateInstallInFlight = true;
810813 clearUpdatePollTimer();
811814 try {
812815 await stopBackendAndWaitForExit();
......@@ -814,10 +817,14 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed
814817 for (const win of BrowserWindow.getAllWindows()) {
815818 win.destroy();
816819 }
820 // `quitAndInstall()` only starts the handoff to the updater. The actual
821 // install may still fail asynchronously, so keep the action incomplete
822 // until we either quit or receive an updater error.
817823 autoUpdater.quitAndInstall(true, true);
818 return { accepted: true, completed: true };
824 return { accepted: true, completed: false };
819825 } catch (error: unknown) {
820826 const message = formatErrorMessage(error);
827 updateInstallInFlight = false;
821828 isQuitting = false;
822829 setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message));
823830 console.error(`[desktop-updater] Failed to install update: ${message}`);
......@@ -854,6 +861,13 @@ function configureAutoUpdater(): void {
854861 }
855862 }
856863
864 if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) {
865 autoUpdater.setFeedURL({
866 provider: "generic",
867 url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`,
868 });
869 }
870
857871 autoUpdater.autoDownload = false;
858872 autoUpdater.autoInstallOnAppQuit = false;
859873 // Keep alpha branding, but force all installs onto the stable update track.
......@@ -890,6 +904,13 @@ function configureAutoUpdater(): void {
890904 });
891905 autoUpdater.on("error", (error) => {
892906 const message = formatErrorMessage(error);
907 if (updateInstallInFlight) {
908 updateInstallInFlight = false;
909 isQuitting = false;
910 setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message));
911 console.error(`[desktop-updater] Updater error: ${message}`);
912 return;
913 }
893914 if (!updateCheckInFlight && !updateDownloadInFlight) {
894915 setUpdateState({
895916 status: "error",
......@@ -1365,6 +1386,7 @@ async function bootstrap(): Promise<void> {
13651386
13661387app.on("before-quit", () => {
13671388 isQuitting = true;
1389 updateInstallInFlight = false;
13681390 writeDesktopLogHeader("before-quit received");
13691391 clearUpdatePollTimer();
13701392 stopBackend();
apps/web/src/components/desktopUpdate.logic.test.ts+10-3
......@@ -145,19 +145,26 @@ describe("getDesktopUpdateActionError", () => {
145145});
146146
147147describe("desktop update UI helpers", () => {
148 it("toasts only for accepted incomplete actions", () => {
148 it("toasts only for actionable updater errors", () => {
149149 expect(
150150 shouldToastDesktopUpdateActionResult({
151151 accepted: true,
152152 completed: false,
153 state: baseState,
153 state: { ...baseState, message: "checksum mismatch" },
154154 }),
155155 ).toBe(true);
156 expect(
157 shouldToastDesktopUpdateActionResult({
158 accepted: true,
159 completed: false,
160 state: { ...baseState, message: null },
161 }),
162 ).toBe(false);
156163 expect(
157164 shouldToastDesktopUpdateActionResult({
158165 accepted: true,
159166 completed: true,
160 state: baseState,
167 state: { ...baseState, message: "checksum mismatch" },
161168 }),
162169 ).toBe(false);
163170 });
apps/web/src/components/desktopUpdate.logic.ts+1-1
......@@ -87,7 +87,7 @@ export function getDesktopUpdateActionError(result: DesktopUpdateActionResult):
8787}
8888
8989export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateActionResult): boolean {
90 return result.accepted && !result.completed;
90 return getDesktopUpdateActionError(result) !== null;
9191}
9292
9393export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | null): boolean {
package.json+1
......@@ -30,6 +30,7 @@
3030 "start": "turbo run start --filter=t3",
3131 "start:desktop": "turbo run start --filter=@t3tools/desktop",
3232 "start:marketing": "turbo run preview --filter=@t3tools/marketing",
33 "start:mock-update-server": "bun run scripts/mock-update-server.ts",
3334 "build": "turbo run build",
3435 "build:marketing": "turbo run build --filter=@t3tools/marketing",
3536 "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3",
scripts/build-desktop-artifact.ts+41-1
......@@ -74,6 +74,8 @@ interface BuildCliInput {
7474 readonly keepStage: Option.Option<boolean>;
7575 readonly signed: Option.Option<boolean>;
7676 readonly verbose: Option.Option<boolean>;
77 readonly mockUpdates: Option.Option<boolean>;
78 readonly mockUpdateServerPort: Option.Option<string>;
7779}
7880
7981function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
......@@ -162,6 +164,8 @@ interface ResolvedBuildOptions {
162164 readonly keepStage: boolean;
163165 readonly signed: boolean;
164166 readonly verbose: boolean;
167 readonly mockUpdates: boolean;
168 readonly mockUpdateServerPort: string | undefined;
165169}
166170
167171interface StagePackageJson {
......@@ -204,6 +208,8 @@ const BuildEnvConfig = Config.all({
204208 keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(false)),
205209 signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(false)),
206210 verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)),
211 mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)),
212 mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),
207213});
208214
209215const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
......@@ -231,13 +237,26 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B
231237 const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget);
232238 const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform));
233239 const version = mergeOptions(input.buildVersion, env.version, undefined);
234 const outputDir = path.resolve(repoRoot, mergeOptions(input.outputDir, env.outputDir, "release"));
240 const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates)
241 ? "release-mock"
242 : "release";
243 const outputDir = path.resolve(
244 repoRoot,
245 mergeOptions(input.outputDir, env.outputDir, releaseDir),
246 );
235247
236248 const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild);
237249 const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage);
238250 const signed = resolveBooleanFlag(input.signed, env.signed);
239251 const verbose = resolveBooleanFlag(input.verbose, env.verbose);
240252
253 const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates);
254 const mockUpdateServerPort = mergeOptions(
255 input.mockUpdateServerPort,
256 env.mockUpdateServerPort,
257 undefined,
258 );
259
241260 return {
242261 platform,
243262 target,
......@@ -248,6 +267,8 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B
248267 keepStage,
249268 signed,
250269 verbose,
270 mockUpdates,
271 mockUpdateServerPort,
251272 } satisfies ResolvedBuildOptions;
252273});
253274
......@@ -447,6 +468,8 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
447468 target: string,
448469 productName: string,
449470 signed: boolean,
471 mockUpdates: boolean,
472 mockUpdateServerPort: string | undefined,
450473) {
451474 const buildConfig: Record<string, unknown> = {
452475 appId: "com.t3tools.t3code",
......@@ -459,6 +482,13 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
459482 const publishConfig = resolveGitHubPublishConfig();
460483 if (publishConfig) {
461484 buildConfig.publish = [publishConfig];
485 } else if (mockUpdates) {
486 buildConfig.publish = [
487 {
488 provider: "generic",
489 url: `http://localhost:${mockUpdateServerPort ?? 3000}`,
490 },
491 ];
462492 }
463493
464494 if (platform === "mac") {
......@@ -631,6 +661,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
631661 options.target,
632662 desktopPackageJson.productName ?? "T3 Code",
633663 options.signed,
664 options.mockUpdates,
665 options.mockUpdateServerPort,
634666 ),
635667 dependencies: {
636668 ...resolvedServerDependencies,
......@@ -769,6 +801,14 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
769801 Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."),
770802 Flag.optional,
771803 ),
804 mockUpdates: Flag.boolean("mock-updates").pipe(
805 Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."),
806 Flag.optional,
807 ),
808 mockUpdateServerPort: Flag.string("mock-update-server-port").pipe(
809 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),
810 Flag.optional,
811 ),
772812}).pipe(
773813 Command.withDescription("Build a desktop artifact for T3 Code."),
774814 Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)),
scripts/mock-update-server.ts created+44
......@@ -0,0 +1,44 @@
1import { resolve, relative } from "node:path";
2import { realpathSync } from "node:fs";
3
4const port = Number(process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000);
5const root =
6 process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT ??
7 resolve(import.meta.dirname, "..", "release-mock");
8
9const mockServerLog = (level: "info" | "warn" | "error" = "info", message: string) => {
10 console[level](`[mock-update-server] ${message}`);
11};
12
13function isWithinRoot(filePath: string): boolean {
14 try {
15 return !relative(realpathSync(root), realpathSync(filePath)).startsWith(".");
16 } catch (error) {
17 mockServerLog("error", `Error checking if file is within root: ${error}`);
18 return false;
19 }
20}
21
22Bun.serve({
23 port,
24 hostname: "localhost",
25 fetch: async (request) => {
26 const url = new URL(request.url);
27 const path = url.pathname;
28 mockServerLog("info", `Request received for path: ${path}`);
29 const filePath = resolve(root, `.${path}`);
30 if (!isWithinRoot(filePath)) {
31 mockServerLog("warn", `Attempted to access file outside of root: ${filePath}`);
32 return new Response("Not Found", { status: 404 });
33 }
34 const file = Bun.file(filePath);
35 if (!(await file.exists())) {
36 mockServerLog("warn", `Attempted to access non-existent file: ${filePath}`);
37 return new Response("Not Found", { status: 404 });
38 }
39 mockServerLog("info", `Serving file: ${filePath}`);
40 return new Response(file.stream());
41 },
42});
43
44mockServerLog("info", `running on http://localhost:${port}`);