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
4 changes: 3 additions & 1 deletion apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,9 @@ const startup = Effect.gen(function* () {
yield* appIdentity.configure;
yield* applicationMenu.configure;
yield* updates.configure;
yield* DesktopRemoteUpdates.listen;
if (environment.isolationProfile === undefined) {
yield* DesktopRemoteUpdates.listen;
}
yield* linuxUrlHandler.register;
yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
}).pipe(Effect.withSpan("desktop.startup"));
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
name: Effect.succeed("T3 Code"),
systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
quit: Effect.void,
exit: () => Effect.void,
relaunch: () => Effect.void,
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ const normalizeCommitHash = (value: string): Option.Option<string> => {

export const resolveUserDataPath = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
if (environment.isolationProfile !== undefined) {
return environment.userDataDirectory;
}
const fileSystem = yield* FileSystem.FileSystem;
const legacyPath = environment.path.join(
environment.appDataDirectory,
Expand Down
36 changes: 35 additions & 1 deletion apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,29 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {
const makeDesktopClerkLayer = (
isDevelopment = true,
events: string[] = [],
isolationProfile?: NonNullable<
DesktopEnvironment.DesktopEnvironment["Service"]["isolationProfile"]
>,
) => {
const environment = DesktopEnvironment.DesktopEnvironment.of({
stateDir: "/tmp/t3-state",
isDevelopment,
appDataDirectory: "/tmp/app-data",
userDataDirectory: isolationProfile?.userDataDirectory ?? "/tmp/app-data/t3code-dev",
isolationProfile,
userDataDirName: isDevelopment ? "t3code-dev" : "t3code",
legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)",
path: { join: (...parts: ReadonlyArray<string>) => parts.join("/") },
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);

const electronApp = {
requestSingleInstanceLock: Effect.sync(() => {
events.push("requestSingleInstanceLock");
return true;
}),
setPath: (name: string, value: string) =>
Effect.sync(() => {
events.push(`setPath:${name}:${value}`);
Expand All @@ -63,6 +75,28 @@ describe("DesktopClerk", () => {
storageMock.mockReset();
});

it.effect("uses the isolated Electron lock without creating a Clerk bridge", () => {
const profile = {
root: "/tmp/t3-profile",
homeDirectory: "/tmp/t3-profile",
appDataDirectory: "/tmp/t3-profile/Library/Application Support",
userDataDirectory: "/tmp/t3-profile/userData",
sessionDataDirectory: "/tmp/t3-profile/sessionData",
t3Home: "/tmp/t3-profile/.t3",
};
const events: string[] = [];

return Effect.gen(function* () {
yield* Effect.scoped(Layer.build(makeDesktopClerkLayer(true, events, profile)));
assert.deepEqual(events, [
`setPath:userData:${profile.userDataDirectory}`,
"requestSingleInstanceLock",
]);
assert.equal(createClerkBridgeMock.mock.calls.length, 0);
assert.equal(storageMock.mock.calls.length, 0);
});
});

it.effect("acquires and releases the SDK bridge with the layer", () => {
const cleanup = vi.fn();
const events: string[] = [];
Expand Down
45 changes: 24 additions & 21 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,27 +97,30 @@ export const make = Effect.gen(function* () {
const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath;
yield* electronApp.setPath("userData", userDataPath);

const bridge = yield* Effect.acquireRelease(
Effect.try({
try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment),
catch: (cause) =>
new DesktopClerkBridgeInitializationError({
stateDir: environment.stateDir,
isDevelopment: environment.isDevelopment,
cause,
}),
}),
(bridge) =>
Effect.try({
try: () => bridge.cleanup(),
catch: (cause) =>
new DesktopClerkBridgeCleanupError({
stateDir: environment.stateDir,
isDevelopment: environment.isDevelopment,
cause,
const isPrimaryInstance =
environment.isolationProfile !== undefined
? yield* electronApp.requestSingleInstanceLock
: yield* Effect.acquireRelease(
Effect.try({
try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment),
catch: (cause) =>
new DesktopClerkBridgeInitializationError({
stateDir: environment.stateDir,
isDevelopment: environment.isDevelopment,
cause,
}),
}),
}).pipe(Effect.orDie),
);
(bridge) =>
Effect.try({
try: () => bridge.cleanup(),
catch: (cause) =>
new DesktopClerkBridgeCleanupError({
stateDir: environment.stateDir,
isDevelopment: environment.isDevelopment,
cause,
}),
}).pipe(Effect.orDie),
).pipe(Effect.map((bridge) => bridge.isPrimaryInstance));

return DesktopClerk.of({
configure: Effect.gen(function* () {
Expand All @@ -131,7 +134,7 @@ export const make = Effect.gen(function* () {
// forwarded to the running app. In a secondary instance the bridge has
// already begun quitting the app; app.quit() is asynchronous, so stop
// bootstrap here before whenReady can fire.
if (!bridge.isPrimaryInstance) {
if (!isPrimaryInstance) {
yield* electronApp.quit;
return yield* Effect.interrupt;
}
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,34 @@ describe("DesktopEnvironment", () => {
}),
);

it.effect("drops inherited OTLP destinations and headers in an isolation profile", () =>
Effect.gen(function* () {
const root = "/tmp/t3-profile";
const environment = yield* makeEnvironment(
{
isolationProfile: {
root,
homeDirectory: root,
appDataDirectory: `${root}/Library/Application Support`,
userDataDirectory: `${root}/userData`,
sessionDataDirectory: `${root}/sessionData`,
t3Home: `${root}/.t3`,
},
},
{
T3CODE_OTLP_TRACES_URL: "https://outside.invalid/traces",
T3CODE_OTLP_HEADERS: "authorization=Bearer%20account-token",
},
);

assert.deepEqual(environment.otlpTracesUrl, Option.none());
assert.deepEqual(environment.otlpMetricsUrl, Option.none());
assert.deepEqual(environment.otlpLogsUrl, Option.none());
assert.deepEqual(environment.otlpHeaders, Option.none());
assert.equal(environment.baseDir, `${root}/.t3`);
}),
);

it.effect("uses the packaged Windows server sidecar as the backend root", () =>
Effect.gen(function* () {
const environment = yield* makeEnvironment({
Expand Down
27 changes: 19 additions & 8 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import type { IsolationProfilePaths } from "@t3tools/shared/isolationRoot";

import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopConfig from "./DesktopConfig.ts";
Expand All @@ -28,6 +29,7 @@ export interface MakeDesktopEnvironmentInput {
readonly isPackaged: boolean;
readonly resourcesPath: string;
readonly runningUnderArm64Translation: boolean;
readonly isolationProfile?: IsolationProfilePaths;
}

export class DesktopEnvironment extends Context.Service<
Expand All @@ -43,6 +45,8 @@ export class DesktopEnvironment extends Context.Service<
readonly appPath: string;
readonly resourcesPath: string;
readonly homeDirectory: string;
readonly isolationProfile: IsolationProfilePaths | undefined;
readonly userDataDirectory: string;
readonly appDataDirectory: string;
readonly baseDir: string;
readonly stateDir: string;
Expand Down Expand Up @@ -154,20 +158,24 @@ const make = Effect.fn("desktop.environment.make")(function* (
const path = yield* Path.Path;
const config = yield* DesktopConfig.DesktopConfig;
const homeDirectory = input.homeDirectory;
const isolationProfile = input.isolationProfile;
const devServerUrl = config.devServerUrl;
const isDevelopment = Option.isSome(devServerUrl);
const appDataDirectory =
input.platform === "win32"
isolationProfile?.appDataDirectory ??
(input.platform === "win32"
? Option.getOrElse(config.appDataDirectory, () =>
path.join(homeDirectory, "AppData", "Roaming"),
)
: input.platform === "darwin"
? path.join(homeDirectory, "Library", "Application Support")
: Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));
: Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")));
const effectiveT3Home =
isolationProfile === undefined ? config.t3Home : Option.some(isolationProfile.t3Home);
const baseDir = resolveDesktopBaseDir({
homeDirectory,
joinPath: path.join,
t3Home: config.t3Home,
t3Home: effectiveT3Home,
});
const rootDir = path.resolve(input.dirname, "../../..");
const appRoot = input.isPackaged ? input.appPath : rootDir;
Expand All @@ -184,7 +192,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
baseDir,
isDevelopment,
joinPath: path.join,
t3Home: config.t3Home,
t3Home: effectiveT3Home,
});
const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
Expand All @@ -205,6 +213,9 @@ const make = Effect.fn("desktop.environment.make")(function* (
appPath: input.appPath,
resourcesPath,
homeDirectory,
isolationProfile,
userDataDirectory:
isolationProfile?.userDataDirectory ?? path.join(appDataDirectory, userDataDirName),
appDataDirectory,
baseDir,
stateDir,
Expand All @@ -228,11 +239,11 @@ const make = Effect.fn("desktop.environment.make")(function* (
devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath,
configuredBackendPort: config.configuredBackendPort,
commitHashOverride: config.commitHashOverride,
otlpTracesUrl: config.otlpTracesUrl,
otlpMetricsUrl: config.otlpMetricsUrl,
otlpLogsUrl: config.otlpLogsUrl,
otlpTracesUrl: isolationProfile === undefined ? config.otlpTracesUrl : Option.none(),
otlpMetricsUrl: isolationProfile === undefined ? config.otlpMetricsUrl : Option.none(),
otlpLogsUrl: isolationProfile === undefined ? config.otlpLogsUrl : Option.none(),
otlpExportIntervalMs: config.otlpExportIntervalMs,
otlpHeaders: config.otlpHeaders,
otlpHeaders: isolationProfile === undefined ? config.otlpHeaders : Option.none(),
otlpProtocol: config.otlpProtocol,
branding,
displayName,
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/app/DesktopIsolationProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as Electron from "electron";
import * as NodeOS from "node:os";

import {
ISOLATION_ROOT_ENV,
resolveDesktopIsolationProfile,
setElectronIsolationPaths,
} from "@t3tools/shared/isolationRoot";

export const isolationProfile = resolveDesktopIsolationProfile({
argv: process.argv,
isPackaged: Electron.app.isPackaged,
// oxlint-disable-next-line t3code/no-global-process-runtime -- The synchronous pre-ready bootstrap must reject unsupported OS targets before Effect services can start.
platform: NodeOS.platform(),
});

if (isolationProfile !== undefined) {
// This app-owned marker is propagated only to owned child processes. It does
// not rewrite HOME or CODEX_HOME and is always resolved from our CLI option.
process.env[ISOLATION_ROOT_ENV] = isolationProfile.root;
// Electron's session and single-instance storage paths are selected during
// startup, before the Clerk bridge and BrowserWindow can initialize them.
setElectronIsolationPaths((name, value) => Electron.app.setPath(name, value), isolationProfile);
}

export const homeDirectory = (): string => isolationProfile?.homeDirectory ?? NodeOS.homedir();
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function makeElectronAppLayer(
name: Effect.succeed("T3 Code"),
systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
quit,
exit: () => Effect.void,
relaunch: () => Effect.void,
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/app/DesktopPreReadyPlatform.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings and prepares the Linux desktop entry synchronously before app services are available.
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand All @@ -10,6 +9,7 @@ import * as Electron from "electron";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts";
import * as DesktopIsolationProfile from "./DesktopIsolationProfile.ts";
import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts";
import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
Expand All @@ -35,7 +35,7 @@ export const resolveEarlyLinuxElectronOptionsFromProcess =
(): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions =>
DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({
env: process.env,
homeDirectory: NodeOS.homedir(),
homeDirectory: DesktopIsolationProfile.homeDirectory(),
joinPath: NodePath.posix.join,
readFileString: (path) => NodeFS.readFileSync(path, "utf8"),
});
Expand Down Expand Up @@ -64,7 +64,7 @@ export const make = Effect.gen(function* () {
try {
const applicationsDir = NodePath.posix.join(
process.env.XDG_DATA_HOME?.trim() ||
NodePath.posix.join(NodeOS.homedir(), ".local", "share"),
NodePath.posix.join(DesktopIsolationProfile.homeDirectory(), ".local", "share"),
"applications",
);
NodeFS.mkdirSync(applicationsDir, { recursive: true });
Expand Down
Loading