Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 1 addition & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const layerElectronApp = (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
132 changes: 83 additions & 49 deletions apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { beforeEach, vi } from "vite-plus/test";

const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({
createClerkBridgeMock: vi.fn(),
storageAdapter: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
storageMock: vi.fn(),
}));
const { createClerkBridgeMock, requestSingleInstanceLockMock, storageAdapter, storageMock } =
vi.hoisted(() => ({
createClerkBridgeMock: vi.fn(),
requestSingleInstanceLockMock: vi.fn(() => true),
storageAdapter: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
storageMock: vi.fn(),
}));

vi.mock("@clerk/electron", () => ({
createClerkBridge: createClerkBridgeMock,
Expand Down Expand Up @@ -70,6 +72,13 @@ const layerDesktopClerk = (
Effect.sync(() => {
events.push(`setPath:${name}:${value}`);
}),
requestSingleInstanceLock: Effect.sync(() => {
events.push("requestSingleInstanceLock");
return requestSingleInstanceLockMock();
}),
quit: Effect.sync(() => {
events.push("quit");
}),
} as unknown as ElectronApp.ElectronApp["Service"];

return DesktopClerk.layer.pipe(
Expand All @@ -88,6 +97,7 @@ const layerDesktopClerk = (
describe("DesktopClerk", () => {
beforeEach(() => {
createClerkBridgeMock.mockReset();
requestSingleInstanceLockMock.mockReset().mockReturnValue(true);
storageMock.mockReset();
});

Expand All @@ -107,22 +117,36 @@ describe("DesktopClerk", () => {
[
{
storage: storageAdapter,
manageSingleInstanceLock: false,
passkeys: true,
renderer: { scheme: "t3code-dev", host: "app" },
},
],
]);
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"]);
assert.deepEqual(events, [
"setPath:userData:/tmp/app-data/t3code-dev",
"requestSingleInstanceLock",
"createClerkBridge",
]);
storageMock.mockClear();
createClerkBridgeMock.mockClear();
});
});

it.each([
{
name: "packaged macOS",
isDevelopment: false,
platform: "darwin" as const,
userData: "/tmp/app-data/t3code-v2",
},
{
name: "packaged Linux",
isDevelopment: false,
platform: "linux" as const,
userData: "/tmp/app-data/t3code-v2",
},
{
name: "packaged Windows",
isDevelopment: false,
Expand Down Expand Up @@ -155,7 +179,11 @@ describe("DesktopClerk", () => {
),
);

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

Expand Down Expand Up @@ -207,63 +235,69 @@ describe("DesktopClerk", () => {
});
});

it.effect("registers the second-instance handler in the primary instance", () => {
it.effect("reveals the running window when a second instance launches", () => {
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
const quit = vi.fn();
const registeredEvents: string[] = [];
const listeners = new Map<string, (...args: unknown[]) => void>();
const revealed = Promise.withResolvers<unknown>();
const window = { id: 1 };
const electronApp = {
quit: Effect.sync(quit),
on: (eventName: string) =>
on: (eventName: string, listener: (...args: unknown[]) => void) =>
Effect.sync(() => {
registeredEvents.push(eventName);
listeners.set(eventName, listener);
}),
} as unknown as ElectronApp.ElectronApp["Service"];
const electronWindow = {} as ElectronWindow.ElectronWindow["Service"];
const electronWindow = {
currentMainOrFirst: Effect.succeedSome(window),
reveal: (value: unknown) => Effect.sync(() => revealed.resolve(value)),
} as unknown as ElectronWindow.ElectronWindow["Service"];

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

assert.isTrue(Exit.isSuccess(exit));
assert.equal(quit.mock.calls.length, 0);
assert.deepEqual(registeredEvents, ["open-url", "open-file", "second-instance"]);
listeners.get("second-instance")!({}, ["t3"]);
assert.strictEqual(yield* Effect.promise(() => revealed.promise), window);
}).pipe(
Effect.scoped,
Effect.provide(layerDesktopClerk()),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
);
});

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));
it.effect.each(["darwin", "win32", "linux"] as const)(
"quits a second launch before initializing Clerk or the application runtime on %s",
(platform) =>
Effect.gen(function* () {
requestSingleInstanceLockMock.mockReturnValueOnce(false);
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
const events: string[] = [];
const runtime = layerDesktopClerk(false, events, platform).pipe(
Layer.flatMap(() =>
Layer.effectDiscard(
Effect.sync(() => {
events.push("initializeApplicationRuntime");
}),
),
),
);
const exit = yield* Effect.exit(Effect.scoped(Layer.build(runtime)));

assert.isTrue(Exit.hasInterrupts(exit));
assert.equal(quit.mock.calls.length, 1);
assert.deepEqual(registeredEvents, []);
}).pipe(
Effect.provide(layerDesktopClerk()),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
);
});
assert.isTrue(Exit.hasInterrupts(exit));
assert.deepEqual(events, [
"setPath:userData:/tmp/app-data/t3code-v2",
"requestSingleInstanceLock",
"quit",
]);
assert.equal(createClerkBridgeMock.mock.calls.length, 0);
assert.equal(storageMock.mock.calls.length, 0);
}),
);
});

it.effect(
Expand Down
21 changes: 9 additions & 12 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos
function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) {
return createClerkBridge({
storage: storage({ path: stateDir }),
manageSingleInstanceLock: false,
passkeys: true,
renderer: {
scheme: ElectronProtocol.getDesktopScheme(isDevelopment),
Expand All @@ -100,12 +101,18 @@ export const make = Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
const shell = yield* ElectronShell.ElectronShell;

// 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);

const bridge = yield* Effect.acquireRelease(
// T3 owns the profile-scoped lock on every platform; Clerk skips it on macOS.
// Quit is asynchronous, so interrupt before the application runtime is built.
if (!(yield* electronApp.requestSingleInstanceLock)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
yield* electronApp.quit;
return yield* Effect.interrupt;
}

yield* Effect.acquireRelease(
Effect.try({
try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment),
catch: (cause) =>
Expand Down Expand Up @@ -135,16 +142,6 @@ export const make = Effect.gen(function* () {
const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
const runPromise = Effect.runPromiseWith(context);

// 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) {
yield* electronApp.quit;
return yield* Effect.interrupt;
}

const startProviderAuthHandoff = (value: string | undefined) => {
if (!value) return false;
const request = readCodexAuthHandoff(value, environment.isDevelopment);
Expand Down
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 layerElectronApp(
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
2 changes: 2 additions & 0 deletions apps/desktop/src/electron/ElectronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class ElectronApp extends Context.Service<
*/
readonly systemLocale: Effect.Effect<string>;
readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>;
readonly requestSingleInstanceLock: Effect.Effect<boolean>;
readonly quit: Effect.Effect<void>;
readonly exit: (code: number) => Effect.Effect<void>;
readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect<void>;
Expand Down Expand Up @@ -137,6 +138,7 @@ export const make = ElectronApp.of({
catch: (cause) => new ElectronAppWhenReadyError({ isPackaged, cause }),
});
}),
requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()),
quit: Effect.sync(() => {
Electron.app.quit();
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function layerElectronApp(
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
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, {
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
Loading