| author | |
| committer | |
| log | f4617e7b19830111d88b1d914b3ca4e1396912a3 |
| tree | 27f554f53d0e22d84ac46a56e36b698a1731145a |
| parent | c426452239e06417ec8e231b7883f9c2292df63c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>7 files changed, 121 insertions(+), 6 deletions(-)
.gitignore+1| ... | @@ -12,6 +12,7 @@ packages/*/dist | ... | @@ -12,6 +12,7 @@ packages/*/dist |
| 12 | build/ | 12 | build/ |
| 13 | .logs/ | 13 | .logs/ |
| 14 | release/ | 14 | release/ |
| 15 | release-mock/ | ||
| 15 | .t3 | 16 | .t3 |
| 16 | .idea/ | 17 | .idea/ |
| 17 | apps/web/.playwright | 18 | apps/web/.playwright |
apps/desktop/src/main.ts+23-1| ... | @@ -287,10 +287,12 @@ let updatePollTimer: ReturnType<typeof setInterval> | null = null; | ... | @@ -287,10 +287,12 @@ let updatePollTimer: ReturnType<typeof setInterval> | null = null; |
| 287 | let updateStartupTimer: ReturnType<typeof setTimeout> | null = null; | 287 | let updateStartupTimer: ReturnType<typeof setTimeout> | null = null; |
| 288 | let updateCheckInFlight = false; | 288 | let updateCheckInFlight = false; |
| 289 | let updateDownloadInFlight = false; | 289 | let updateDownloadInFlight = false; |
| 290 | let updateInstallInFlight = false; | ||
| 290 | let updaterConfigured = false; | 291 | let updaterConfigured = false; |
| 291 | let updateState: DesktopUpdateState = initialUpdateState(); | 292 | let updateState: DesktopUpdateState = initialUpdateState(); |
| 292 | 293 | ||
| 293 | function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { | 294 | function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { |
| 295 | if (updateInstallInFlight) return "install"; | ||
| 294 | if (updateDownloadInFlight) return "download"; | 296 | if (updateDownloadInFlight) return "download"; |
| 295 | if (updateCheckInFlight) return "check"; | 297 | if (updateCheckInFlight) return "check"; |
| 296 | return updateState.errorContext; | 298 | return updateState.errorContext; |
| ... | @@ -807,6 +809,7 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed | ... | @@ -807,6 +809,7 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed |
| 807 | } | 809 | } |
| 808 | 810 | ||
| 809 | isQuitting = true; | 811 | isQuitting = true; |
| 812 | updateInstallInFlight = true; | ||
| 810 | clearUpdatePollTimer(); | 813 | clearUpdatePollTimer(); |
| 811 | try { | 814 | try { |
| 812 | await stopBackendAndWaitForExit(); | 815 | await stopBackendAndWaitForExit(); |
| ... | @@ -814,10 +817,14 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed | ... | @@ -814,10 +817,14 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed |
| 814 | for (const win of BrowserWindow.getAllWindows()) { | 817 | for (const win of BrowserWindow.getAllWindows()) { |
| 815 | win.destroy(); | 818 | win.destroy(); |
| 816 | } | 819 | } |
| 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. | ||
| 817 | autoUpdater.quitAndInstall(true, true); | 823 | autoUpdater.quitAndInstall(true, true); |
| 818 | return { accepted: true, completed: true }; | 824 | return { accepted: true, completed: false }; |
| 819 | } catch (error: unknown) { | 825 | } catch (error: unknown) { |
| 820 | const message = formatErrorMessage(error); | 826 | const message = formatErrorMessage(error); |
| 827 | updateInstallInFlight = false; | ||
| 821 | isQuitting = false; | 828 | isQuitting = false; |
| 822 | setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); | 829 | setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); |
| 823 | console.error(`[desktop-updater] Failed to install update: ${message}`); | 830 | console.error(`[desktop-updater] Failed to install update: ${message}`); |
| ... | @@ -854,6 +861,13 @@ function configureAutoUpdater(): void { | ... | @@ -854,6 +861,13 @@ function configureAutoUpdater(): void { |
| 854 | } | 861 | } |
| 855 | } | 862 | } |
| 856 | 863 | ||
| 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 | |||
| 857 | autoUpdater.autoDownload = false; | 871 | autoUpdater.autoDownload = false; |
| 858 | autoUpdater.autoInstallOnAppQuit = false; | 872 | autoUpdater.autoInstallOnAppQuit = false; |
| 859 | // Keep alpha branding, but force all installs onto the stable update track. | 873 | // Keep alpha branding, but force all installs onto the stable update track. |
| ... | @@ -890,6 +904,13 @@ function configureAutoUpdater(): void { | ... | @@ -890,6 +904,13 @@ function configureAutoUpdater(): void { |
| 890 | }); | 904 | }); |
| 891 | autoUpdater.on("error", (error) => { | 905 | autoUpdater.on("error", (error) => { |
| 892 | const message = formatErrorMessage(error); | 906 | 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 | } | ||
| 893 | if (!updateCheckInFlight && !updateDownloadInFlight) { | 914 | if (!updateCheckInFlight && !updateDownloadInFlight) { |
| 894 | setUpdateState({ | 915 | setUpdateState({ |
| 895 | status: "error", | 916 | status: "error", |
| ... | @@ -1365,6 +1386,7 @@ async function bootstrap(): Promise<void> { | ... | @@ -1365,6 +1386,7 @@ async function bootstrap(): Promise<void> { |
| 1365 | 1386 | ||
| 1366 | app.on("before-quit", () => { | 1387 | app.on("before-quit", () => { |
| 1367 | isQuitting = true; | 1388 | isQuitting = true; |
| 1389 | updateInstallInFlight = false; | ||
| 1368 | writeDesktopLogHeader("before-quit received"); | 1390 | writeDesktopLogHeader("before-quit received"); |
| 1369 | clearUpdatePollTimer(); | 1391 | clearUpdatePollTimer(); |
| 1370 | stopBackend(); | 1392 | stopBackend(); |
apps/web/src/components/desktopUpdate.logic.test.ts+10-3| ... | @@ -145,19 +145,26 @@ describe("getDesktopUpdateActionError", () => { | ... | @@ -145,19 +145,26 @@ describe("getDesktopUpdateActionError", () => { |
| 145 | }); | 145 | }); |
| 146 | 146 | ||
| 147 | describe("desktop update UI helpers", () => { | 147 | describe("desktop update UI helpers", () => { |
| 148 | it("toasts only for accepted incomplete actions", () => { | 148 | it("toasts only for actionable updater errors", () => { |
| 149 | expect( | 149 | expect( |
| 150 | shouldToastDesktopUpdateActionResult({ | 150 | shouldToastDesktopUpdateActionResult({ |
| 151 | accepted: true, | 151 | accepted: true, |
| 152 | completed: false, | 152 | completed: false, |
| 153 | state: baseState, | 153 | state: { ...baseState, message: "checksum mismatch" }, |
| 154 | }), | 154 | }), |
| 155 | ).toBe(true); | 155 | ).toBe(true); |
| 156 | expect( | ||
| 157 | shouldToastDesktopUpdateActionResult({ | ||
| 158 | accepted: true, | ||
| 159 | completed: false, | ||
| 160 | state: { ...baseState, message: null }, | ||
| 161 | }), | ||
| 162 | ).toBe(false); | ||
| 156 | expect( | 163 | expect( |
| 157 | shouldToastDesktopUpdateActionResult({ | 164 | shouldToastDesktopUpdateActionResult({ |
| 158 | accepted: true, | 165 | accepted: true, |
| 159 | completed: true, | 166 | completed: true, |
| 160 | state: baseState, | 167 | state: { ...baseState, message: "checksum mismatch" }, |
| 161 | }), | 168 | }), |
| 162 | ).toBe(false); | 169 | ).toBe(false); |
| 163 | }); | 170 | }); |
apps/web/src/components/desktopUpdate.logic.ts+1-1| ... | @@ -87,7 +87,7 @@ export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): | ... | @@ -87,7 +87,7 @@ export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): |
| 87 | } | 87 | } |
| 88 | 88 | ||
| 89 | export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateActionResult): boolean { | 89 | export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateActionResult): boolean { |
| 90 | return result.accepted && !result.completed; | 90 | return getDesktopUpdateActionError(result) !== null; |
| 91 | } | 91 | } |
| 92 | 92 | ||
| 93 | export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | null): boolean { | 93 | export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | null): boolean { |
package.json+1| ... | @@ -30,6 +30,7 @@ | ... | @@ -30,6 +30,7 @@ |
| 30 | "start": "turbo run start --filter=t3", | 30 | "start": "turbo run start --filter=t3", |
| 31 | "start:desktop": "turbo run start --filter=@t3tools/desktop", | 31 | "start:desktop": "turbo run start --filter=@t3tools/desktop", |
| 32 | "start:marketing": "turbo run preview --filter=@t3tools/marketing", | 32 | "start:marketing": "turbo run preview --filter=@t3tools/marketing", |
| 33 | "start:mock-update-server": "bun run scripts/mock-update-server.ts", | ||
| 33 | "build": "turbo run build", | 34 | "build": "turbo run build", |
| 34 | "build:marketing": "turbo run build --filter=@t3tools/marketing", | 35 | "build:marketing": "turbo run build --filter=@t3tools/marketing", |
| 35 | "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", | 36 | "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", |
scripts/build-desktop-artifact.ts+41-1| ... | @@ -74,6 +74,8 @@ interface BuildCliInput { | ... | @@ -74,6 +74,8 @@ interface BuildCliInput { |
| 74 | readonly keepStage: Option.Option<boolean>; | 74 | readonly keepStage: Option.Option<boolean>; |
| 75 | readonly signed: Option.Option<boolean>; | 75 | readonly signed: Option.Option<boolean>; |
| 76 | readonly verbose: Option.Option<boolean>; | 76 | readonly verbose: Option.Option<boolean>; |
| 77 | readonly mockUpdates: Option.Option<boolean>; | ||
| 78 | readonly mockUpdateServerPort: Option.Option<string>; | ||
| 77 | } | 79 | } |
| 78 | 80 | ||
| 79 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { | 81 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { |
| ... | @@ -162,6 +164,8 @@ interface ResolvedBuildOptions { | ... | @@ -162,6 +164,8 @@ interface ResolvedBuildOptions { |
| 162 | readonly keepStage: boolean; | 164 | readonly keepStage: boolean; |
| 163 | readonly signed: boolean; | 165 | readonly signed: boolean; |
| 164 | readonly verbose: boolean; | 166 | readonly verbose: boolean; |
| 167 | readonly mockUpdates: boolean; | ||
| 168 | readonly mockUpdateServerPort: string | undefined; | ||
| 165 | } | 169 | } |
| 166 | 170 | ||
| 167 | interface StagePackageJson { | 171 | interface StagePackageJson { |
| ... | @@ -204,6 +208,8 @@ const BuildEnvConfig = Config.all({ | ... | @@ -204,6 +208,8 @@ const BuildEnvConfig = Config.all({ |
| 204 | keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(false)), | 208 | keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(false)), |
| 205 | signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(false)), | 209 | signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(false)), |
| 206 | verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), | 210 | 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), | ||
| 207 | }); | 213 | }); |
| 208 | 214 | ||
| 209 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | 215 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => |
| ... | @@ -231,13 +237,26 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B | ... | @@ -231,13 +237,26 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B |
| 231 | const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); | 237 | const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); |
| 232 | const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform)); | 238 | const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform)); |
| 233 | const version = mergeOptions(input.buildVersion, env.version, undefined); | 239 | 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 | ); | ||
| 235 | 247 | ||
| 236 | const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild); | 248 | const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild); |
| 237 | const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage); | 249 | const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage); |
| 238 | const signed = resolveBooleanFlag(input.signed, env.signed); | 250 | const signed = resolveBooleanFlag(input.signed, env.signed); |
| 239 | const verbose = resolveBooleanFlag(input.verbose, env.verbose); | 251 | const verbose = resolveBooleanFlag(input.verbose, env.verbose); |
| 240 | 252 | ||
| 253 | const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates); | ||
| 254 | const mockUpdateServerPort = mergeOptions( | ||
| 255 | input.mockUpdateServerPort, | ||
| 256 | env.mockUpdateServerPort, | ||
| 257 | undefined, | ||
| 258 | ); | ||
| 259 | |||
| 241 | return { | 260 | return { |
| 242 | platform, | 261 | platform, |
| 243 | target, | 262 | target, |
| ... | @@ -248,6 +267,8 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B | ... | @@ -248,6 +267,8 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B |
| 248 | keepStage, | 267 | keepStage, |
| 249 | signed, | 268 | signed, |
| 250 | verbose, | 269 | verbose, |
| 270 | mockUpdates, | ||
| 271 | mockUpdateServerPort, | ||
| 251 | } satisfies ResolvedBuildOptions; | 272 | } satisfies ResolvedBuildOptions; |
| 252 | }); | 273 | }); |
| 253 | 274 | ||
| ... | @@ -447,6 +468,8 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -447,6 +468,8 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 447 | target: string, | 468 | target: string, |
| 448 | productName: string, | 469 | productName: string, |
| 449 | signed: boolean, | 470 | signed: boolean, |
| 471 | mockUpdates: boolean, | ||
| 472 | mockUpdateServerPort: string | undefined, | ||
| 450 | ) { | 473 | ) { |
| 451 | const buildConfig: Record<string, unknown> = { | 474 | const buildConfig: Record<string, unknown> = { |
| 452 | appId: "com.t3tools.t3code", | 475 | appId: "com.t3tools.t3code", |
| ... | @@ -459,6 +482,13 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -459,6 +482,13 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 459 | const publishConfig = resolveGitHubPublishConfig(); | 482 | const publishConfig = resolveGitHubPublishConfig(); |
| 460 | if (publishConfig) { | 483 | if (publishConfig) { |
| 461 | buildConfig.publish = [publishConfig]; | 484 | buildConfig.publish = [publishConfig]; |
| 485 | } else if (mockUpdates) { | ||
| 486 | buildConfig.publish = [ | ||
| 487 | { | ||
| 488 | provider: "generic", | ||
| 489 | url: `http://localhost:${mockUpdateServerPort ?? 3000}`, | ||
| 490 | }, | ||
| 491 | ]; | ||
| 462 | } | 492 | } |
| 463 | 493 | ||
| 464 | if (platform === "mac") { | 494 | if (platform === "mac") { |
| ... | @@ -631,6 +661,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -631,6 +661,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 631 | options.target, | 661 | options.target, |
| 632 | desktopPackageJson.productName ?? "T3 Code", | 662 | desktopPackageJson.productName ?? "T3 Code", |
| 633 | options.signed, | 663 | options.signed, |
| 664 | options.mockUpdates, | ||
| 665 | options.mockUpdateServerPort, | ||
| 634 | ), | 666 | ), |
| 635 | dependencies: { | 667 | dependencies: { |
| 636 | ...resolvedServerDependencies, | 668 | ...resolvedServerDependencies, |
| ... | @@ -769,6 +801,14 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { | ... | @@ -769,6 +801,14 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { |
| 769 | Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."), | 801 | Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."), |
| 770 | Flag.optional, | 802 | Flag.optional, |
| 771 | ), | 803 | ), |
| 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 | ), | ||
| 772 | }).pipe( | 812 | }).pipe( |
| 773 | Command.withDescription("Build a desktop artifact for T3 Code."), | 813 | Command.withDescription("Build a desktop artifact for T3 Code."), |
| 774 | Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), | 814 | Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), |
scripts/mock-update-server.ts created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | import { resolve, relative } from "node:path"; | ||
| 2 | import { realpathSync } from "node:fs"; | ||
| 3 | |||
| 4 | const port = Number(process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000); | ||
| 5 | const root = | ||
| 6 | process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT ?? | ||
| 7 | resolve(import.meta.dirname, "..", "release-mock"); | ||
| 8 | |||
| 9 | const mockServerLog = (level: "info" | "warn" | "error" = "info", message: string) => { | ||
| 10 | console[level](`[mock-update-server] ${message}`); | ||
| 11 | }; | ||
| 12 | |||
| 13 | function 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 | |||
| 22 | Bun.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 | |||
| 44 | mockServerLog("info", `running on http://localhost:${port}`); | ||