Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 12 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopInstallIntegrity from "./DesktopInstallIntegrity.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
import * as DesktopObservability from "./DesktopObservability.ts";
Expand Down Expand Up @@ -283,6 +284,17 @@ const startup = Effect.gen(function* () {
backend: Option.getOrElse(selectedBackend, () => "unknown"),
});
}
// Refuse to boot from a half-applied update (app.asar and
// app.asar.unpacked stamped by different builds) before anything loads
// from the mismatched halves — the alternative is an opaque
// ERR_MODULE_NOT_FOUND crash in the main process.
const downloadPageUrl = yield* DesktopInstallIntegrity.resolveDownloadPageUrl;
const installIntact = yield* DesktopInstallIntegrity.enforceInstallIntegrity({
downloadPageUrl,
});
if (!installIntact) {
return;
}
yield* appIdentity.configure;
yield* applicationMenu.configure;
yield* updates.configure;
Expand Down
173 changes: 173 additions & 0 deletions apps/desktop/src/app/DesktopInstallIntegrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";

import * as DesktopConfig from "./DesktopConfig.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopInstallIntegrity from "./DesktopInstallIntegrity.ts";

const makeEnvironmentLayer = (input: {
readonly resourcesPath: string;
readonly isPackaged: boolean;
readonly homeDirectory: string;
}) =>
DesktopEnvironment.layer({
dirname: "/repo/apps/desktop/src",
homeDirectory: input.homeDirectory,
platform: "win32",
processArch: "x64",
appVersion: "1.2.3",
appPath: "/repo",
isPackaged: input.isPackaged,
resourcesPath: input.resourcesPath,
runningUnderArm64Translation: false,
}).pipe(
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
DesktopConfig.layerTest({
T3CODE_HOME: input.homeDirectory,
}),
),
),
);

const withInstallDir = <A, E, R>(
build: (input: {
readonly resourcesPath: string;
readonly manifestPath: string;
}) => Effect.Effect<A, E, R>,
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-install-integrity-test-",
});
const resourcesPath = path.join(baseDir, "resources");
const manifestPath = path.join(
resourcesPath,
...DesktopInstallIntegrity.DESKTOP_BUILD_MANIFEST_RELATIVE_PATH.split("/"),
);
yield* fileSystem.makeDirectory(path.dirname(manifestPath), { recursive: true });
return yield* build({ resourcesPath, manifestPath });
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer));

describe("DesktopInstallIntegrity", () => {
it.effect("accepts an install whose unpacked build stamp matches the asar version", () =>
withInstallDir(({ resourcesPath, manifestPath }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.writeFileString(manifestPath, '{"version":"1.2.3"}\n');

const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe(
Effect.provide(
makeEnvironmentLayer({
resourcesPath,
isPackaged: true,
homeDirectory: `/tmp/t3-integrity-home-${process.pid}`,
}),
),
);
assert.equal(result._tag, "Ok");
}),
),
);

it.effect("flags a half-applied update whose unpacked files came from a different build", () =>
withInstallDir(({ resourcesPath, manifestPath }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
// The observed corruption: app.asar (and its version) stayed at the
// old build while app.asar.unpacked was replaced by the new one.
yield* fileSystem.writeFileString(manifestPath, '{"version":"1.2.4-nightly.1"}\n');

const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe(
Effect.provide(
makeEnvironmentLayer({
resourcesPath,
isPackaged: true,
homeDirectory: `/tmp/t3-integrity-home-${process.pid}`,
}),
),
);
assert.equal(result._tag, "Mismatch");
if (result._tag === "Mismatch") {
assert.equal(result.appVersion, "1.2.3");
assert.equal(result.unpackedVersion, "1.2.4-nightly.1");
}
}),
),
);

it.effect("skips when no manifest exists (pre-stamp installs, macOS/Linux, dev)", () =>
withInstallDir(({ resourcesPath }) =>
Effect.gen(function* () {
const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe(
Effect.provide(
makeEnvironmentLayer({
resourcesPath,
isPackaged: true,
homeDirectory: `/tmp/t3-integrity-home-${process.pid}`,
}),
),
);
assert.equal(result._tag, "Skipped");
}),
),
);

it.effect("skips undecodable manifests instead of blocking launch", () =>
withInstallDir(({ resourcesPath, manifestPath }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.writeFileString(manifestPath, "not json");

const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe(
Effect.provide(
makeEnvironmentLayer({
resourcesPath,
isPackaged: true,
homeDirectory: `/tmp/t3-integrity-home-${process.pid}`,
}),
),
);
assert.equal(result._tag, "Skipped");
}),
),
);

it.effect("resolves the releases page from the packaged update feed", () =>
withInstallDir(({ resourcesPath }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const environmentLayer = makeEnvironmentLayer({
resourcesPath,
isPackaged: true,
homeDirectory: `/tmp/t3-integrity-home-${process.pid}`,
});
const appUpdateYmlPath = yield* DesktopEnvironment.DesktopEnvironment.pipe(
Effect.map((environment) => environment.appUpdateYmlPath),
Effect.provide(environmentLayer),
);
yield* fileSystem.makeDirectory(
appUpdateYmlPath.slice(0, appUpdateYmlPath.lastIndexOf("/")),
{ recursive: true },
);
yield* fileSystem.writeFileString(
appUpdateYmlPath,
"provider: github\nowner: pingdotgg\nrepo: t3code\n",
);

const url = yield* DesktopInstallIntegrity.resolveDownloadPageUrl.pipe(
Effect.provide(environmentLayer),
);
assert.deepEqual(url, Option.some("https://github.com/pingdotgg/t3code/releases"));
}),
),
);
});
190 changes: 190 additions & 0 deletions apps/desktop/src/app/DesktopInstallIntegrity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Post-apply install integrity check. A Windows update apply is not atomic:
// NSIS replaces files one by one, and a file held open (e.g. via a WSL 9p
// handle) is silently skipped in silent-install mode, leaving app.asar and
// app.asar.unpacked from DIFFERENT builds. That state crashes later with an
// opaque ERR_MODULE_NOT_FOUND in the main process. The desktop build stamps
// apps/server/dist/desktop-build-manifest.json (asar-unpacked on Windows)
// with the build version; comparing it against the asar's own version at
// startup detects a half-applied update before anything loads from the
// mismatched halves, and turns the opaque crash into an actionable dialog.

import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";

import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopObservability from "./DesktopObservability.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopState from "./DesktopState.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";

// Kept in sync with scripts/build-desktop-artifact.ts, which writes the
// manifest into the staged apps/server/dist so it lands under the Windows
// asarUnpack globs.
export const DESKTOP_BUILD_MANIFEST_RELATIVE_PATH =
"app.asar.unpacked/apps/server/dist/desktop-build-manifest.json";

export const DesktopBuildManifest = Schema.Struct({
version: Schema.String,
});
export type DesktopBuildManifest = typeof DesktopBuildManifest.Type;

const decodeDesktopBuildManifest = Schema.decodeUnknownEffect(
Schema.fromJsonString(DesktopBuildManifest),
);

export type DesktopInstallIntegrityResult =
| { readonly _tag: "Ok" }
// No manifest to compare against: dev runs, macOS/Linux (which pack the
// server dist inside the asar), installs predating the stamp, or an
// unreadable/corrupt manifest. Never blocks launch.
| { readonly _tag: "Skipped"; readonly reason: string }
| {
readonly _tag: "Mismatch";
readonly appVersion: string;
readonly unpackedVersion: string;
readonly manifestPath: string;
};

const { logWarning: logIntegrityWarning, logError: logIntegrityError } =
DesktopObservability.makeComponentLogger("desktop-install-integrity");

export const checkInstallIntegrity: Effect.Effect<
DesktopInstallIntegrityResult,
never,
DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem
> = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;

if (!environment.isPackaged) {
return { _tag: "Skipped", reason: "not a packaged build" } as const;
}

const manifestPath = environment.path.join(
environment.resourcesPath,
...DESKTOP_BUILD_MANIFEST_RELATIVE_PATH.split("/"),
);
const raw = yield* fileSystem.readFileString(manifestPath, "utf-8").pipe(Effect.option);
Comment thread
Jgratton24 marked this conversation as resolved.
Outdated
if (Option.isNone(raw)) {
return { _tag: "Skipped", reason: `no build manifest at ${manifestPath}` } as const;
}
Comment thread
Jgratton24 marked this conversation as resolved.
Outdated

const manifest = yield* decodeDesktopBuildManifest(raw.value).pipe(Effect.option);
if (Option.isNone(manifest)) {
// A corrupt manifest could itself be fallout from a partial apply, but a
// false positive here bricks every launch — log loudly and let the
// version comparison stay the only hard gate.
yield* logIntegrityWarning("build manifest exists but could not be decoded", {
manifestPath,
});
return { _tag: "Skipped", reason: `undecodable build manifest at ${manifestPath}` } as const;
}

if (manifest.value.version === environment.appVersion) {
return { _tag: "Ok" } as const;
}

return {
_tag: "Mismatch",
appVersion: environment.appVersion,
unpackedVersion: manifest.value.version,
manifestPath,
} as const;
}).pipe(Effect.withSpan("desktop.installIntegrity.check"));

// Best-effort "where to get a fresh installer" link for the repair dialog,
// derived from the packaged update feed (app-update.yml). None when the
// build has no feed configured.
export const resolveDownloadPageUrl: Effect.Effect<
Option.Option<string>,
never,
DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem
> = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const raw = yield* fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe(
Effect.option,
);
if (Option.isNone(raw)) {
return Option.none<string>();
}
const owner = raw.value.match(/^owner:\s*(.+)$/m)?.[1]?.trim();
const repo = raw.value.match(/^repo:\s*(.+)$/m)?.[1]?.trim();
if (!owner || !repo) {
return Option.none<string>();
}
return Option.some(`https://github.com/${owner}/${repo}/releases`);
});

// Runs the check and, on mismatch, refuses to continue launching: shows an
// actionable dialog (with a jump to the releases page when the update feed
// names one) and requests shutdown. Returns false when startup must stop.
export const enforceInstallIntegrity = (options: {
readonly downloadPageUrl: Option.Option<string>;
}): Effect.Effect<
boolean,
never,
| DesktopEnvironment.DesktopEnvironment
| FileSystem.FileSystem
| DesktopShutdown.DesktopShutdown
| DesktopState.DesktopState
| ElectronApp.ElectronApp
| ElectronDialog.ElectronDialog
| ElectronShell.ElectronShell
> =>
Effect.gen(function* () {
const result = yield* checkInstallIntegrity;
if (result._tag === "Ok") {
return true;
}
if (result._tag === "Skipped") {
return true;
}

const shutdown = yield* DesktopShutdown.DesktopShutdown;
const state = yield* DesktopState.DesktopState;
const electronApp = yield* ElectronApp.ElectronApp;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const electronShell = yield* ElectronShell.ElectronShell;

yield* logIntegrityError(
"install is internally inconsistent (half-applied update); refusing to launch",
{
appVersion: result.appVersion,
unpackedVersion: result.unpackedVersion,
manifestPath: result.manifestPath,
},
);

const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
if (!wasQuitting) {
const message =
`This T3 Code installation is damaged: a previous update was only partially applied ` +
`(application core is version ${result.appVersion}, support files are version ${result.unpackedVersion}). ` +
`Please reinstall T3 Code to repair it.`;
const buttons = Option.isSome(options.downloadPageUrl)
? ["Open Download Page", "Quit"]
: ["Quit"];
const response = yield* electronDialog
.showMessageBox({
type: "error",
title: "T3 Code installation is damaged",
message,
buttons,
defaultId: 0,
cancelId: buttons.length - 1,
})
.pipe(Effect.orElseSucceed(() => ({ response: buttons.length - 1 })));
if (Option.isSome(options.downloadPageUrl) && response.response === 0) {
yield* electronShell.openExternal(options.downloadPageUrl.value).pipe(Effect.ignore);
}
}
yield* shutdown.request;
yield* electronApp.quit;
return false;
}).pipe(Effect.withSpan("desktop.installIntegrity.enforce"));
Loading
Loading