Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ packages/*/dist
build/
.logs/
release/
release-mock/
.t3
.idea/
apps/web/.playwright
Expand Down
24 changes: 23 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,12 @@ let updatePollTimer: ReturnType<typeof setInterval> | null = null;
let updateStartupTimer: ReturnType<typeof setTimeout> | 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;
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1334,6 +1355,7 @@ async function bootstrap(): Promise<void> {

app.on("before-quit", () => {
isQuitting = true;
updateInstallInFlight = false;
writeDesktopLogHeader("before-quit received");
clearUpdatePollTimer();
stopBackend();
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/desktopUpdate.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/desktopUpdate.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 41 additions & 1 deletion scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ interface BuildCliInput {
readonly keepStage: Option.Option<boolean>;
readonly signed: Option.Option<boolean>;
readonly verbose: Option.Option<boolean>;
readonly mockUpdates: Option.Option<boolean>;
readonly mockUpdateServerPort: Option.Option<string>;
}

function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
Expand Down Expand Up @@ -162,6 +164,8 @@ interface ResolvedBuildOptions {
readonly keepStage: boolean;
readonly signed: boolean;
readonly verbose: boolean;
readonly mockUpdates: boolean;
readonly mockUpdateServerPort: string | undefined;
}

interface StagePackageJson {
Expand Down Expand Up @@ -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<boolean>, envValue: boolean) =>
Expand Down Expand Up @@ -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,
Expand All @@ -248,6 +267,8 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B
keepStage,
signed,
verbose,
mockUpdates,
mockUpdateServerPort,
} satisfies ResolvedBuildOptions;
});

Expand Down Expand Up @@ -447,6 +468,8 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
target: string,
productName: string,
signed: boolean,
mockUpdates: boolean,
mockUpdateServerPort: string | undefined,
) {
const buildConfig: Record<string, unknown> = {
appId: "com.t3tools.t3code",
Expand All @@ -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") {
Expand Down Expand Up @@ -631,6 +661,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
options.target,
desktopPackageJson.productName ?? "T3 Code",
options.signed,
options.mockUpdates,
options.mockUpdateServerPort,
),
dependencies: {
...resolvedServerDependencies,
Expand Down Expand Up @@ -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)),
Expand Down
45 changes: 45 additions & 0 deletions scripts/mock-update-server.ts
Original file line number Diff line number Diff line change
@@ -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}`);