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
1 change: 0 additions & 1 deletion apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
calls.setAboutPanelOptions.push(options);
}),
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
getAppMetrics: Effect.succeed([]),
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
Expand Down
46 changes: 27 additions & 19 deletions apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ const normalizeCommitHash = (value: string): Option.Option<string> => {
: Option.none();
};

export const resolveUserDataPath = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const legacyPath = environment.path.join(
environment.appDataDirectory,
environment.legacyUserDataDirName,
);
const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe(
Effect.mapError(
(cause) =>
new DesktopUserDataPathResolutionError({
legacyPath,
cause,
}),
),
);
return legacyPathExists
? legacyPath
: environment.path.join(environment.appDataDirectory, environment.userDataDirName);
}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath"));

export const make = Effect.gen(function* () {
const assets = yield* DesktopAssets.DesktopAssets;
const electronApp = yield* ElectronApp.ElectronApp;
Expand Down Expand Up @@ -90,24 +111,11 @@ export const make = Effect.gen(function* () {
return commitHash;
});

const resolveUserDataPath = Effect.gen(function* () {
const legacyPath = environment.path.join(
environment.appDataDirectory,
environment.legacyUserDataDirName,
);
const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe(
Effect.mapError(
(cause) =>
new DesktopUserDataPathResolutionError({
legacyPath,
cause,
}),
),
);
return legacyPathExists
? legacyPath
: environment.path.join(environment.appDataDirectory, environment.userDataDirName);
}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath"));
const userDataPath = resolveUserDataPath.pipe(
Effect.provide(
yield* Effect.context<DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem>(),
),
);

const configure = Effect.gen(function* () {
const commitHash = yield* resolveAboutCommitHash;
Expand Down Expand Up @@ -136,7 +144,7 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.withSpan("desktop.appIdentity.configure"));

return DesktopAppIdentity.of({
resolveUserDataPath,
resolveUserDataPath: userDataPath,
configure,
});
});
Expand Down
95 changes: 90 additions & 5 deletions apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,38 @@ vi.mock("@clerk/electron/storage", () => ({
storage: storageMock,
}));

import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
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) => {
const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {
const environment = DesktopEnvironment.DesktopEnvironment.of({
stateDir: "/tmp/t3-state",
isDevelopment,
appDataDirectory: "/tmp/app-data",
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 = {
setPath: (name: string, value: string) =>
Effect.sync(() => {
events.push(`setPath:${name}:${value}`);
}),
} as unknown as ElectronApp.ElectronApp["Service"];

return DesktopClerk.layer.pipe(
Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)),
Layer.provide(
Layer.mergeAll(
Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment),
Layer.succeed(ElectronApp.ElectronApp, electronApp),
FileSystem.layerNoop({ exists: () => Effect.succeed(false) }),
),
),
);
};

Expand All @@ -55,11 +76,15 @@ describe("DesktopClerk", () => {

it.effect("acquires and releases the SDK bridge with the layer", () => {
const cleanup = vi.fn();
const events: string[] = [];
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue({ cleanup });
createClerkBridgeMock.mockImplementation(() => {
events.push("createClerkBridge");
return { cleanup, isPrimaryInstance: true };
});

return Effect.gen(function* () {
yield* Effect.scoped(Layer.build(makeDesktopClerkLayer()));
yield* Effect.scoped(Layer.build(makeDesktopClerkLayer(true, events)));

assert.deepEqual(createClerkBridgeMock.mock.calls, [
[
Expand All @@ -71,6 +96,10 @@ describe("DesktopClerk", () => {
],
]);
assert.equal(cleanup.mock.calls.length, 1);
// The bridge acquires Electron's single-instance lock at creation, and
// the lock both lives in and creates the userData directory — so the
// real path must be set before the bridge exists.
assert.deepEqual(events, ["setPath:userData:/tmp/app-data/t3code-dev", "createClerkBridge"]);
storageMock.mockClear();
createClerkBridgeMock.mockClear();
});
Expand Down Expand Up @@ -124,11 +153,67 @@ describe("DesktopClerk", () => {
});
});

it.effect("registers the second-instance handler in the primary instance", () => {
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
const quit = vi.fn();
const registeredEvents: string[] = [];
const electronApp = {
quit: Effect.sync(quit),
on: (eventName: string) =>
Effect.sync(() => {
registeredEvents.push(eventName);
}),
} as unknown as ElectronApp.ElectronApp["Service"];
const electronWindow = {} as ElectronWindow.ElectronWindow["Service"];

return Effect.gen(function* () {
const clerk = yield* DesktopClerk.DesktopClerk;
const exit = yield* Effect.exit(Effect.scoped(clerk.configure));

assert.isTrue(Exit.isSuccess(exit));
assert.equal(quit.mock.calls.length, 0);
assert.deepEqual(registeredEvents, ["second-instance"]);
}).pipe(
Effect.provide(makeDesktopClerkLayer()),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
);
});

it.effect("quits and interrupts startup in a secondary instance", () => {
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: false });
const quit = vi.fn();
const registeredEvents: string[] = [];
const electronApp = {
quit: Effect.sync(quit),
on: (eventName: string) =>
Effect.sync(() => {
registeredEvents.push(eventName);
}),
} as unknown as ElectronApp.ElectronApp["Service"];
const electronWindow = {} as ElectronWindow.ElectronWindow["Service"];

return Effect.gen(function* () {
const clerk = yield* DesktopClerk.DesktopClerk;
const exit = yield* Effect.exit(Effect.scoped(clerk.configure));

assert.isTrue(Exit.hasInterrupts(exit));
assert.equal(quit.mock.calls.length, 1);
assert.deepEqual(registeredEvents, []);
}).pipe(
Effect.provide(makeDesktopClerkLayer()),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
);
});

it.each([
{ isDevelopment: true, scheme: "t3code-dev" },
{ isDevelopment: false, scheme: "t3code" },
])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => {
const bridge = { cleanup: vi.fn() };
const bridge = { cleanup: vi.fn(), isPrimaryInstance: true };
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue(bridge);

Expand Down
21 changes: 19 additions & 2 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/rela
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
Expand Down Expand Up @@ -84,7 +85,18 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea

export const make = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
yield* Effect.acquireRelease(
const electronApp = yield* ElectronApp.ElectronApp;

// Electron scopes the single-instance lock to the userData directory and
// creates that directory when the lock is acquired. The SDK bridge takes
// the lock at creation, so userData must already point at the real
// directory here — under the default productName-derived path, acquiring
// the lock would create "T3 Code (Alpha)" and make the legacy-install
// detection in resolveUserDataPath match on fresh installs.
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) =>
Expand Down Expand Up @@ -113,7 +125,12 @@ export const make = Effect.gen(function* () {
const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
const runPromise = Effect.runPromiseWith(context);

if (!(yield* electronApp.requestSingleInstanceLock)) {
// The SDK bridge holds Electron's single-instance lock (acquired at
// bridge creation) so OAuth deep-link callbacks on Windows/Linux are
// 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) {
Comment thread
cursor[bot] marked this conversation as resolved.
yield* electronApp.quit;
return yield* Effect.interrupt;
}
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ describe("DesktopLifecycle", () => {
setName: () => Effect.void,
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
getAppMetrics: Effect.succeed([]),
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
Expand Down
3 changes: 0 additions & 3 deletions apps/desktop/src/electron/ElectronApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const {
quitMock,
relaunchMock,
removeListenerMock,
requestSingleInstanceLockMock,
setAboutPanelOptionsMock,
setAppUserModelIdMock,
setAsDefaultProtocolClientMock,
Expand All @@ -35,7 +34,6 @@ const {
quitMock: vi.fn(),
relaunchMock: vi.fn(),
removeListenerMock: vi.fn(),
requestSingleInstanceLockMock: vi.fn(() => true),
setAboutPanelOptionsMock: vi.fn(),
setAppUserModelIdMock: vi.fn(),
setAsDefaultProtocolClientMock: vi.fn(() => true),
Expand Down Expand Up @@ -67,7 +65,6 @@ vi.mock("electron", () => ({
quit: quitMock,
relaunch: relaunchMock,
removeListener: removeListenerMock,
requestSingleInstanceLock: requestSingleInstanceLockMock,
runningUnderARM64Translation: false,
setAboutPanelOptions: setAboutPanelOptionsMock,
setAsDefaultProtocolClient: setAsDefaultProtocolClientMock,
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/electron/ElectronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ export class ElectronApp extends Context.Service<
options: Electron.AboutPanelOptionsOptions,
) => Effect.Effect<void>;
readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
readonly requestSingleInstanceLock: Effect.Effect<boolean>;
readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>;
readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;
readonly setAsDefaultProtocolClient: (
Expand Down Expand Up @@ -153,7 +152,6 @@ export const make = ElectronApp.of({
Effect.sync(() => {
Electron.app.setAppUserModelId(id);
}),
requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()),
getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()),
isDefaultProtocolClient: (protocol) =>
Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ function makeElectronAppLayer(
setName: () => Effect.void,
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
getAppMetrics: Effect.sync(() => {
onMetricsRead();
return metrics;
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
setName: () => Effect.void,
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
getAppMetrics: Effect.succeed([]),
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
Expand Down
Loading
Loading