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
52 changes: 49 additions & 3 deletions apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,21 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
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[] = []) => {
import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts";

const makeDesktopClerkLayer = (
isDevelopment = true,
events: string[] = [],
platform: NodeJS.Platform = "darwin",
fileSystemLayer: Layer.Layer<FileSystem.FileSystem> = FileSystem.layerNoop({
exists: () => Effect.succeed(false),
}),
) => {
const environment = DesktopEnvironment.DesktopEnvironment.of({
stateDir: "/tmp/t3-state",
isDevelopment,
appDataDirectory: "/tmp/app-data",
platform,
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);

const electronApp = {
Expand All @@ -50,7 +59,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {
NodePath.layerPosix,
Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment),
Layer.succeed(ElectronApp.ElectronApp, electronApp),
FileSystem.layerNoop({ exists: () => Effect.succeed(false) }),
fileSystemLayer,
),
),
);
Expand Down Expand Up @@ -93,6 +102,43 @@ describe("DesktopClerk", () => {
});
});

it.each([
{
name: "packaged Windows",
isDevelopment: false,
platform: "win32" as const,
userData: "/tmp/app-data/t3code-v2",
},
{
name: "development",
isDevelopment: true,
platform: "win32" as const,
userData: "/tmp/app-data/t3code-dev",
},
])(
"creates the bridge before startup can yield to the event loop ($name)",
({ isDevelopment, platform, userData }) => {
const events: string[] = [];
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockImplementation(() => {
events.push("createClerkBridge");
return { cleanup: vi.fn(), isPrimaryInstance: true };
});
// runSync throws if the layer ever suspends, which would let Electron emit
// ready before the bridge exists. main.ts provides the same FileSystem.
// oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- The assertion IS that the layer builds synchronously; it.effect would mask a regression to async.
Effect.runSync(
Effect.scoped(
Layer.build(
makeDesktopClerkLayer(isDevelopment, events, platform, DesktopPreReadyFileSystem.layer),
),
),
);

assert.deepEqual(events, [`setPath:userData:${userData}`, "createClerkBridge"]);
},
);

it.effect("preserves bridge initialization failures", () => {
const cause = new Error("bridge initialization failed");
storageMock.mockReturnValue(storageAdapter);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const make = Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;

// The SDK bridge acquires Electron's profile-scoped single-instance lock.
// Must not yield: the bridge registers a scheme Electron rejects once ready.
const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment);
yield* electronApp.setPath("userData", userDataPath);

Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts";
import * as DesktopUserData from "./DesktopUserData.ts";

const resolveWindowsUserData = (appDataDirectory: string) =>
DesktopUserData.resolveUserDataPath({
appDataDirectory,
isDevelopment: false,
platform: "win32",
}).pipe(Effect.provide(DesktopPreReadyFileSystem.layer));

it.layer(NodeServices.layer)("DesktopPreReadyFileSystem", (it) => {
it.effect("migrates the legacy Windows profile state", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pre-ready-fs-" });
yield* fileSystem.makeDirectory(path.join(root, "T3 Code (Alpha)"));
yield* fileSystem.writeFileString(path.join(root, "T3 Code (Alpha)", "Local State"), "keys");

const userData = yield* resolveWindowsUserData(root);

assert.equal(userData, path.join(root, "t3code-v2"));
assert.equal(yield* fileSystem.readFileString(path.join(userData, "Local State")), "keys");
}),
);

it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32" || process.getuid?.() === 0)(
"fails instead of treating an unreadable profile as missing",
() =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pre-ready-fs-" });
yield* fileSystem.chmod(root, 0o000);
yield* Effect.addFinalizer(() => fileSystem.chmod(root, 0o700).pipe(Effect.orDie));

const exit = yield* Effect.exit(resolveWindowsUserData(root));

assert.isTrue(Exit.isFailure(exit));
}),
);
});
65 changes: 65 additions & 0 deletions apps/desktop/src/app/DesktopPreReadyFileSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @effect-diagnostics nodeBuiltinImport:off -- Effect's Node FileSystem is async, and pre-ready startup must not yield before the Clerk bridge registers its privileged scheme.
import * as NodeFS from "node:fs";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as PlatformError from "effect/PlatformError";

const systemErrorTag = (cause: unknown): PlatformError.SystemErrorTag => {
switch ((cause as NodeJS.ErrnoException | undefined)?.code) {
case "EEXIST":
return "AlreadyExists";
case "ENOENT":
return "NotFound";
case "EACCES":
case "EPERM":
return "PermissionDenied";
default:
return "Unknown";
}
};

const syncFs = <A>(method: string, path: string, run: () => A) =>
Effect.try({
try: run,
catch: (cause) =>
PlatformError.systemError({
_tag: systemErrorTag(cause),
module: "FileSystem",
method,
pathOrDescriptor: path,
cause,
}),
});

/**
* A synchronous FileSystem for startup work that runs before Electron's ready.
* Electron emits ready as soon as startup yields to the event loop. Only the
* operations userData resolution needs are implemented.
*
* @public Service construction is part of the canonical Effect module API.
*/
export const make = FileSystem.makeNoop({
// Like Effect's exists: only a missing path is false, other access errors fail.
exists: (path) =>
syncFs("exists", path, () => {
NodeFS.accessSync(path);
return true;
}).pipe(
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() => Effect.succeed(false),
),
),
readFileString: (path) => syncFs("readFileString", path, () => NodeFS.readFileSync(path, "utf8")),
makeDirectory: (path, options) =>
syncFs("makeDirectory", path, () => {
NodeFS.mkdirSync(path, { recursive: options?.recursive ?? false });
}),
writeFileString: (path, data, options) =>
syncFs("writeFileString", path, () =>
NodeFS.writeFileSync(path, data, { flag: options?.flag ?? "w" }),
),
});

export const layer = Layer.succeed(FileSystem.FileSystem, make);
7 changes: 5 additions & 2 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts";
import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts";
import * as DesktopSnapShot from "./snapShot/DesktopSnapShot.ts";
import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
import * as DesktopPreReadyFileSystem from "./app/DesktopPreReadyFileSystem.ts";
import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts";
import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
Expand Down Expand Up @@ -204,7 +205,9 @@ const desktopApplicationLayer = Layer.mergeAll(
Layer.provideMerge(desktopLocalEnvironmentAuthLayer),
);

// Clerk resolves userData before Electron is ready, so it gets the synchronous FileSystem.
const desktopClerkLayer = DesktopClerk.layer.pipe(
Layer.provide(DesktopPreReadyFileSystem.layer),
Layer.provideMerge(desktopEnvironmentLayer),
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(ElectronApp.layer),
Expand All @@ -217,8 +220,8 @@ const desktopApplicationRuntimeLayer = desktopApplicationLayer.pipe(
Layer.provideMerge(electronLayer),
);

// Acquire strict pre-ready setup before Clerk, whose userData resolution can
// yield and let Electron emit ready.
// Acquire strict pre-ready setup before Clerk. Nothing before the Clerk bridge
// may yield, or Electron can emit ready before Clerk registers its scheme.
const desktopRuntimeLayer = desktopClerkLayer.pipe(
Layer.flatMap((clerkContext) =>
desktopApplicationRuntimeLayer.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))),
Expand Down
Loading