Skip to content
Merged
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 @@ -287,10 +287,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 @@ -807,17 +809,22 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed
}

isQuitting = true;
updateInstallInFlight = true;
clearUpdatePollTimer();
try {
await stopBackendAndWaitForExit();
// Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close.
for (const win of BrowserWindow.getAllWindows()) {
win.destroy();
}
// `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(true, true);
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 @@ -854,6 +861,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}`,
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Truthy env check enables mock updates with "false"

Medium Severity

The runtime check process.env.T3CODE_DESKTOP_MOCK_UPDATES is a truthy check, so setting it to "false" or "0" still enables mock updates, redirecting the auto-updater to a localhost server that likely isn't running. This silently breaks update checks. The codebase already uses strict comparison for similar env vars (e.g., T3CODE_DISABLE_AUTO_UPDATE === "1"), and the build-time counterpart uses Config.boolean which correctly parses "false" as false.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

@nmggithub nmggithub Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is expected behavior. Mock updates are opt-in, so passing T3CODE_DESKTOP_MOCK_UPDATES=false is not a sane, normal, or expected use of the packaging script.


autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
// Keep alpha branding, but force all installs onto the stable update track.
Expand Down Expand Up @@ -890,6 +904,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 @@ -1365,6 +1386,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}`,
},
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mock updates config ignored when GitHub config exists

Medium Severity

At build time, the else if means mockUpdates is silently ignored whenever resolveGitHubPublishConfig() returns a config (e.g. when GITHUB_REPOSITORY is set). At runtime in configureAutoUpdater, mock updates correctly override GitHub by running after the GitHub setFeedURL call. This inconsistency means a build with --mock-updates can produce an app-update.yml pointing to GitHub instead of localhost, contradicting the PR's stated intent that the flag controls "the manifest URL built into the built Electron app."

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

@nmggithub nmggithub Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existence of a GitHub publish config and a mock update path are mutually exclusive. This is desired behavior. This conflict would only happen if someone were to pass --mock-updates and supply a GITHUB_REPOSITORY value, which is not a sane, normal, or expected use of the packaging script.

}

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
44 changes: 44 additions & 0 deletions scripts/mock-update-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { resolve, relative } from "node:path";
import { realpathSync } from "node:fs";

const port = Number(process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000);
const root =
process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT ??
resolve(import.meta.dirname, "..", "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(".");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path containment check incorrectly blocks dotfiles

Low Severity

The isWithinRoot check uses startsWith(".") on the relative() result, which rejects both .. parent-traversal paths and legitimate dotfiles at the root level (e.g., .gitkeep). A file like root/.something produces a relative path of ".something" which starts with "." and gets rejected. The check needs startsWith("..") instead to only block directory traversal while allowing dotfiles.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The update server does not make use of dotfiles. This should be fine.

} 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}`);