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
21 changes: 21 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,27 @@ describe("DesktopEnvironment", () => {
}),
);

it.effect("shares the release install's data, renaming only the branding", () =>
Effect.gen(function* () {
const release = yield* makeEnvironment({ isPackaged: true });
const flavored = yield* makeEnvironment({
isPackaged: true,
flavor: { id: "marc", label: "Marc" },
});

// The Electron profile comes from DesktopUserData, which never sees the flavor.
assert.equal(flavored.baseDir, release.baseDir);
assert.equal(flavored.stateDir, release.stateDir);
assert.equal(flavored.appDataDirectory, release.appDataDirectory);
assert.deepEqual(flavored.branding, {
baseName: "T3 Code",
stageLabel: "Dev",
displayName: "T3 Code (Marc)",
});
assert.equal(release.branding.displayName, "T3 Code (Alpha)");
}),
);

it.effect("resolves picker defaults without nullish sentinels", () =>
Effect.gen(function* () {
const environment = yield* makeEnvironment();
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as Path from "effect/Path";

import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopConfig from "./DesktopConfig.ts";
import type { DesktopFlavor } from "./DesktopFlavor.ts";
import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts";
import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts";
import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
Expand All @@ -28,6 +29,7 @@ export interface MakeDesktopEnvironmentInput {
readonly isPackaged: boolean;
readonly resourcesPath: string;
readonly runningUnderArm64Translation: boolean;
readonly flavor?: DesktopFlavor | undefined;
}

export class DesktopEnvironment extends Context.Service<
Expand Down Expand Up @@ -80,6 +82,8 @@ export class DesktopEnvironment extends Context.Service<
readonly otlpHeaders: Option.Option<Record<string, string>>;
readonly otlpProtocol: OtlpProtocol;
readonly branding: DesktopAppBranding;
// Personal build identity from the packaged package.json (see DesktopFlavor.ts).
readonly flavor?: DesktopFlavor | undefined;
readonly displayName: string;
readonly appUserModelId: string;
readonly linuxDesktopEntryName: string;
Expand Down Expand Up @@ -109,7 +113,18 @@ function resolveDesktopAppStageLabel(input: {
export function resolveDesktopAppBranding(input: {
readonly isDevelopment: boolean;
readonly appVersion: string;
readonly flavor?: DesktopFlavor | undefined;
}): DesktopAppBranding {
// A flavored build is a local, unreleased build: it wears the Dev stage art
// but names itself after the flavor. Only the branding differs; it shares
// the release install's data, Electron profile and single-instance lock.
if (input.flavor) {
return {
baseName: APP_BASE_NAME,
stageLabel: "Dev",
displayName: `${APP_BASE_NAME} (${input.flavor.label})`,
};
}
const stageLabel = resolveDesktopAppStageLabel(input);
return {
baseName: APP_BASE_NAME,
Expand Down Expand Up @@ -178,6 +193,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
const branding = resolveDesktopAppBranding({
isDevelopment,
appVersion: input.appVersion,
flavor: input.flavor,
});
const displayName = branding.displayName;
const stateDir = resolveDesktopStateDir({
Expand Down Expand Up @@ -234,6 +250,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
otlpHeaders: config.otlpHeaders,
otlpProtocol: config.otlpProtocol,
branding,
flavor: input.flavor,
displayName,
appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>
isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code",
Expand Down
161 changes: 161 additions & 0 deletions apps/desktop/src/app/DesktopFlavor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// @effect-diagnostics nodeBuiltinImport:off - the test writes a packaged package.json fixture to disk.
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import { assert, describe, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import {
type DesktopFlavor,
findLiveServerOwner,
readPackagedDesktopFlavor,
refuseSharedDataInUse,
} from "./DesktopFlavor.ts";

function appPathWithPackageJson(packageJson: unknown): string {
const appPath = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-flavor-"));
NodeFS.writeFileSync(NodePath.join(appPath, "package.json"), JSON.stringify(packageJson));
return appPath;
}

function stateDirWithRuntime(runtime: unknown): string {
const stateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-runtime-"));
NodeFS.writeFileSync(NodePath.join(stateDir, "server-runtime.json"), JSON.stringify(runtime));
return stateDir;
}
const runtime = { version: 1, pid: 4242, host: "127.0.0.1", port: 3773 };

describe("readPackagedDesktopFlavor", () => {
it("reads the flavor a flavored build recorded in its package.json", () => {
const appPath = appPathWithPackageJson({
name: "t3code",
t3codeDesktopFlavor: { id: "marc", label: "Marc" },
});
assert.deepEqual(
readPackagedDesktopFlavor({ appPath, isPackaged: true }),
Option.some({ id: "marc", label: "Marc" }),
);
// Unpackaged runs never take a flavor, even from a stray field.
assert.deepEqual(readPackagedDesktopFlavor({ appPath, isPackaged: false }), Option.none());
});

it("keeps the release identity without the field or the file", () => {
assert.deepEqual(
readPackagedDesktopFlavor({
appPath: appPathWithPackageJson({ name: "t3code" }),
isPackaged: true,
}),
Option.none(),
);
assert.deepEqual(
readPackagedDesktopFlavor({
appPath: NodePath.join(NodeOS.tmpdir(), "t3-flavor-missing"),
isPackaged: true,
}),
Option.none(),
);
});

it("refuses a malformed flavor instead of falling back to the release data", () => {
const appPath = appPathWithPackageJson({ t3codeDesktopFlavor: { id: "Not Valid" } });
assert.throws(() => readPackagedDesktopFlavor({ appPath, isPackaged: true }));
});
});

describe("findLiveServerOwner", () => {
it("reports a live server that owns the state directory", () => {
assert.equal(
findLiveServerOwner(stateDirWithRuntime(runtime), (pid) => pid === 4242),
4242,
);
});

it("ignores a stale, missing or unreadable runtime file", () => {
assert.equal(
findLiveServerOwner(stateDirWithRuntime(runtime), () => false),
null,
);
assert.equal(
findLiveServerOwner(NodePath.join(NodeOS.tmpdir(), "t3-runtime-missing"), () => true),
null,
);
assert.equal(
findLiveServerOwner(stateDirWithRuntime({ pid: "x" }), () => true),
null,
);
});

it("does not count its own process as another owner", () => {
assert.equal(
findLiveServerOwner(stateDirWithRuntime({ ...runtime, pid: process.pid }), () => true),
null,
);
});
});

describe("refuseSharedDataInUse", () => {
const marc: DesktopFlavor = { id: "marc", label: "Marc" };

const runGuard = Effect.fn(function* (input: {
readonly flavor: DesktopFlavor | undefined;
readonly stateDir: string;
}) {
const events: string[] = [];
const services = Layer.mergeAll(
Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
flavor: input.flavor,
stateDir: input.stateDir,
displayName: "T3 Code (Marc)",
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]),
Layer.succeed(ElectronDialog.ElectronDialog, {
showErrorBox: (title: string) => Effect.sync(() => events.push(`errorBox:${title}`)),
} as unknown as ElectronDialog.ElectronDialog["Service"]),
Layer.succeed(ElectronApp.ElectronApp, {
quit: Effect.sync(() => events.push("quit")),
} as unknown as ElectronApp.ElectronApp["Service"]),
);
const exit = yield* Effect.exit(refuseSharedDataInUse.pipe(Effect.provide(services)));
return { exit, events };
});

it.effect("stops a flavored build while a live server owns the shared data", () =>
Effect.gen(function* () {
// The test runner's parent process stands in for the running release app.
const { exit, events } = yield* runGuard({
flavor: marc,
stateDir: stateDirWithRuntime({ ...runtime, pid: process.ppid }),
});
assert.isTrue(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause));
assert.deepEqual(events, ["errorBox:T3 Code (Marc) cannot start", "quit"]);
}),
);

it.effect("lets a flavored build start when no live server owns the data", () =>
Effect.gen(function* () {
const { exit, events } = yield* runGuard({
flavor: marc,
stateDir: NodePath.join(NodeOS.tmpdir(), "t3-runtime-missing"),
});
assert.isTrue(Exit.isSuccess(exit));
assert.deepEqual(events, []);
}),
);

it.effect("never stops the release build", () =>
Effect.gen(function* () {
const { exit, events } = yield* runGuard({
flavor: undefined,
stateDir: stateDirWithRuntime({ ...runtime, pid: process.ppid }),
});
assert.isTrue(Exit.isSuccess(exit));
assert.deepEqual(events, []);
}),
);
});
111 changes: 111 additions & 0 deletions apps/desktop/src/app/DesktopFlavor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @effect-diagnostics nodeBuiltinImport:off - the flavor and the shared-data owner are read synchronously before the Clerk bridge; an async read yields and lets Electron emit ready before Clerk registers its privileged schemes.
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

/**
* Personal build identity that `T3CODE_DESKTOP_FLAVOR` bakes into the packaged
* package.json (see scripts/build-desktop-artifact.ts). It only changes the
* app's branding; data, Electron profile and keychain entry stay the release
* install's, guarded by refuseSharedDataInUse.
*/
export const DesktopFlavor = Schema.Struct({
id: Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9-]*$/u)),
label: Schema.String,
});
export type DesktopFlavor = typeof DesktopFlavor.Type;

const ServerRuntimeOwner = Schema.Struct({ pid: Schema.Int });
const decodeServerRuntimeOwner = Schema.decodeSync(Schema.fromJsonString(ServerRuntimeOwner));

const PackageFlavorMetadata = Schema.Struct({
t3codeDesktopFlavor: Schema.optional(DesktopFlavor),
});
const decodePackageFlavorMetadata = Schema.decodeSync(Schema.fromJsonString(PackageFlavorMetadata));

/**
* A missing package.json or field means the release identity. A malformed
* field throws so a broken build fails loudly instead of posing as the release.
*/
export function readPackagedDesktopFlavor(input: {
readonly appPath: string;
readonly isPackaged: boolean;
}): Option.Option<DesktopFlavor> {
if (!input.isPackaged) return Option.none();
let raw: string;
try {
raw = NodeFS.readFileSync(NodePath.join(input.appPath, "package.json"), "utf8");
} catch {
return Option.none();
}
return Option.fromNullishOr(decodePackageFlavorMetadata(raw).t3codeDesktopFlavor);
}

function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERM: the process exists but belongs to someone else.
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}

/**
* The pid of a live T3 server that already owns this state directory, from the
* server-runtime.json it writes while listening. A flavored build shares the
* release install's data, and neither the release app (no single-instance lock
* on macOS) nor the server guards that directory, so the flavored app checks
* before starting a second server on it. A stale or unreadable file is no owner.
*/
export function findLiveServerOwner(
stateDir: string,
isAlive: (pid: number) => boolean = isProcessAlive,
): number | null {
let pid: number;
try {
pid = decodeServerRuntimeOwner(
NodeFS.readFileSync(NodePath.join(stateDir, "server-runtime.json"), "utf8"),
).pid;
} catch {
return null;
}
return pid !== process.pid && isAlive(pid) ? pid : null;
}

/**
* Stops a flavored build while another live server owns the shared data. Built
* before the Clerk bridge points userData at the shared profile, and like the
* bridge it must not yield: Electron emits ready, and Chromium opens the
* profile, as soon as startup yields to the event loop. So it reads
* synchronously, logs to the console and reports with showErrorBox, which is
* safe before ready.
*/
export const refuseSharedDataInUse = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
if (!environment.flavor) return;
const ownerPid = findLiveServerOwner(environment.stateDir);
if (ownerPid === null) return;

yield* Effect.logWarning("another T3 Code server owns the shared data; not starting", {
pid: ownerPid,
stateDir: environment.stateDir,
});
const electronDialog = yield* ElectronDialog.ElectronDialog;
yield* electronDialog.showErrorBox(
`${environment.displayName} cannot start`,
`Another T3 Code app (process ${ownerPid}) is using ${environment.stateDir}. Quit it first, then open ${environment.displayName} again.`,
);
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.quit;
return yield* Effect.interrupt;
});

export const layerSharedDataGuard = Layer.effectDiscard(refuseSharedDataInUse);
5 changes: 5 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
import serverPackageJson from "../../server/package.json" with { type: "json" };

import * as DesktopIpc from "./ipc/DesktopIpc.ts";
import * as DesktopFlavor from "./app/DesktopFlavor.ts";
import * as ElectronApp from "./electron/ElectronApp.ts";
import * as ElectronDialog from "./electron/ElectronDialog.ts";
import * as ElectronMenu from "./electron/ElectronMenu.ts";
Expand Down Expand Up @@ -93,12 +94,14 @@ const layerDesktopEnvironment = Layer.unwrap(
);
const platform = yield* HostProcess.Platform;
const processArch = yield* HostProcess.Architecture;
const flavor = DesktopFlavor.readPackagedDesktopFlavor(metadata);
return DesktopEnvironment.layer({
dirname: __dirname,
homeDirectory: NodeOS.homedir(),
platform,
processArch,
...metadata,
flavor: Option.getOrUndefined(flavor),
});
}),
);
Expand Down Expand Up @@ -229,8 +232,10 @@ const layerDesktopApplication = Layer.mergeAll(
);

// Clerk resolves userData before Electron is ready, so it gets the synchronous FileSystem.
// A flavored build first checks that no other server owns that shared data.
const layerDesktopClerk = DesktopClerk.layer.pipe(
Layer.provide(DesktopPreReadyFileSystem.layer),
Layer.provide(DesktopFlavor.layerSharedDataGuard.pipe(Layer.provide(ElectronDialog.layer))),
Layer.provideMerge(ElectronShell.layer),
Layer.provideMerge(layerDesktopEnvironment),
Layer.provideMerge(NodeServices.layer),
Expand Down
Loading
Loading