diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index b4d0d14ca62e..29633dd6889d 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -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.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 = { @@ -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, ), ), ); @@ -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); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index cc19f9f0e66c..8e27112acea6 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -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); diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts new file mode 100644 index 000000000000..ab4c4f86cdf1 --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts @@ -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)); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts new file mode 100644 index 000000000000..8ce58f7ebbb3 --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -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 = (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); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0d626a51955d..ed52d1b7c7a6 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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"; @@ -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), @@ -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))),