Skip to content
Draft
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
28 changes: 27 additions & 1 deletion apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopDeepLinks from "./DesktopDeepLinks.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
Expand All @@ -20,6 +21,7 @@ import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopObservability from "./DesktopObservability.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
import { readLiveExistingBackend } from "../backend/DesktopExistingBackend.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
import * as DesktopState from "./DesktopState.ts";
Expand Down Expand Up @@ -151,7 +153,18 @@ const bootstrap = Effect.gen(function* () {
return yield* new DesktopDevelopmentBackendPortRequiredError();
}

const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort);
const existingBackend = readLiveExistingBackend(environment.stateDir);
const existingBackendPort = existingBackend
? Option.some(new URL(existingBackend.httpBaseUrl).port).pipe(
Option.flatMap((port) => {
const parsed = Number.parseInt(port, 10);
return Number.isSafeInteger(parsed) ? Option.some(parsed) : Option.none();
}),
)
: Option.none<number>();
const backendPortSelection = yield* resolveDesktopBackendPort(
Option.orElse(existingBackendPort, () => environment.configuredBackendPort),
);
const backendPort = backendPortSelection.port;
yield* logBootstrapInfo(
backendPortSelection.selectedByScan
Expand All @@ -162,6 +175,13 @@ const bootstrap = Effect.gen(function* () {
...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}),
},
);
if (existingBackend) {
yield* logBootstrapInfo("reusing existing backend for shared T3 home", {
pid: existingBackend.pid,
httpBaseUrl: existingBackend.httpBaseUrl,
stateDir: existingBackend.stateDir,
});
}

const settings = yield* desktopSettings.get;
if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) {
Expand Down Expand Up @@ -213,6 +233,12 @@ const bootstrap = Effect.gen(function* () {
// slow first wsl.exe spawn.
yield* Effect.forkScoped(wslBackend.reconcile);
}

// Catalog + window services are usable; flush any deep link captured from
// initial argv / open-url during single-instance setup.
const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks;
yield* deepLinks.start;
yield* logBootstrapInfo("bootstrap deep links ready");
}).pipe(Effect.withSpan("desktop.bootstrap"));

const startup = Effect.gen(function* () {
Expand Down
152 changes: 151 additions & 1 deletion apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import { beforeEach, vi } from "vite-plus/test";

const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({
Expand All @@ -22,13 +23,16 @@ vi.mock("@clerk/electron/storage", () => ({
storage: storageMock,
}));

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopDeepLinks from "./DesktopDeepLinks.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

const makeDesktopClerkLayer = (isDevelopment = true) => {
const makeDesktopClerkLayer = (isDevelopment = true, isPackaged = false) => {
const environment = DesktopEnvironment.DesktopEnvironment.of({
stateDir: "/tmp/t3-state",
isDevelopment,
isPackaged,
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);

return DesktopClerk.layer.pipe(
Expand Down Expand Up @@ -146,4 +150,150 @@ describe("DesktopClerk", () => {
storageMock.mockClear();
createClerkBridgeMock.mockClear();
});

it.effect(
"wires second-instance argv into deep links and registers the protocol when packaged",
() => {
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() });

return Effect.gen(function* () {
const handledArgv = yield* Ref.make<Array<readonly string[]>>([]);
const handledUrls = yield* Ref.make<string[]>([]);
const listeners = new Map<string, (...args: readonly unknown[]) => void>();
let protocolClientRegistered = false;

const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, {
handleArgv: (argv) =>
Ref.update(handledArgv, (items) => [...items, argv]).pipe(Effect.asVoid),
handleUrl: (url) =>
Ref.update(handledUrls, (items) => [...items, url]).pipe(Effect.asVoid),
start: Effect.void,
} satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]);

const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata"),
name: Effect.succeed("T3 Code"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
relaunch: () => Effect.void,
setPath: () => Effect.void,
setName: () => Effect.void,
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: (protocol: string) =>
Effect.sync(() => {
protocolClientRegistered = protocol === "t3code";
return true;
}),
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
appendCommandLineSwitch: () => Effect.void,
on: <Args extends ReadonlyArray<unknown>>(
eventName: string,
listener: (...args: Args) => void,
) =>
Effect.sync(() => {
listeners.set(eventName, listener as (...args: readonly unknown[]) => void);
}).pipe(Effect.asVoid),
} as unknown as ElectronApp.ElectronApp["Service"]);

const runtimeLayer = Layer.mergeAll(
makeDesktopClerkLayer(false, true),
deepLinksLayer,
electronAppLayer,
);

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

assert.isTrue(protocolClientRegistered);
assert.isTrue(listeners.has("second-instance"));
assert.isTrue(listeners.has("open-url"));

// Initial process.argv is captured during configure.
const initialHandled = yield* Ref.get(handledArgv);
assert.isTrue(initialHandled.length >= 1);

const secondInstance = listeners.get("second-instance");
assert.isDefined(secondInstance);
secondInstance?.({}, [
"t3code",
"t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463",
]);
// Allow the fire-and-forget runPromise callback to settle.
yield* Effect.yieldNow;
yield* Effect.yieldNow;

const afterSecond = yield* Ref.get(handledArgv);
assert.isTrue(
afterSecond.some((argv) =>
argv.some((entry) => entry.startsWith("t3code://open/thread")),
),
);

const openUrl = listeners.get("open-url");
assert.isDefined(openUrl);
const preventDefault = vi.fn();
openUrl?.(
{ preventDefault },
"t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
);
yield* Effect.yieldNow;
yield* Effect.yieldNow;
assert.equal(preventDefault.mock.calls.length, 1);
const urls = yield* Ref.get(handledUrls);
assert.deepEqual(urls, [
"t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
]);
}).pipe(Effect.provide(runtimeLayer)),
);
});
},
);

it.effect("does not register the OS protocol client in development", () => {
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() });

return Effect.gen(function* () {
let protocolClientRegistered = false;

const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, {
handleArgv: () => Effect.void,
handleUrl: () => Effect.void,
start: Effect.void,
} satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]);

const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
requestSingleInstanceLock: Effect.succeed(true),
setAsDefaultProtocolClient: () =>
Effect.sync(() => {
protocolClientRegistered = true;
return true;
}),
on: () => Effect.void,
quit: Effect.void,
} as unknown as ElectronApp.ElectronApp["Service"]);

const runtimeLayer = Layer.mergeAll(
makeDesktopClerkLayer(true, false),
deepLinksLayer,
electronAppLayer,
);

yield* Effect.scoped(
Effect.gen(function* () {
const clerk = yield* DesktopClerk.DesktopClerk;
yield* clerk.configure;
assert.isFalse(protocolClientRegistered);
}).pipe(Effect.provide(runtimeLayer)),
);
});
});
});
39 changes: 25 additions & 14 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@ import { storage } from "@clerk/electron/storage";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";

import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopDeepLinks from "./DesktopDeepLinks.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
Expand Down Expand Up @@ -47,7 +46,7 @@ export class DesktopClerk extends Context.Service<
readonly configure: Effect.Effect<
void,
never,
ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope
DesktopDeepLinks.DesktopDeepLinks | ElectronApp.ElectronApp | Scope.Scope
>;
}
>()("@t3tools/desktop/app/DesktopClerk") {}
Expand Down Expand Up @@ -109,25 +108,37 @@ export const make = Effect.gen(function* () {
return DesktopClerk.of({
configure: Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks;
// Capture ambient services for Electron event callbacks, which cannot yield.
const context = yield* Effect.context<never>();
const runPromise = Effect.runPromiseWith(context);

if (!(yield* electronApp.requestSingleInstanceLock)) {
yield* electronApp.quit;
return yield* Effect.interrupt;
}

yield* electronApp.on("second-instance", () => {
void runPromise(
Effect.gen(function* () {
const mainWindow = yield* electronWindow.currentMainOrFirst;
if (Option.isSome(mainWindow)) {
yield* electronWindow.reveal(mainWindow.value);
}
}),
);
// Register before readiness so cold-start and second-instance deep links
// are not dropped. Deep-link processing itself queues until start().
yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[] = []) => {
void runPromise(deepLinks.handleArgv(argv));
});

// macOS delivers custom URL scheme activations through open-url.
yield* electronApp.on("open-url", (event: { preventDefault?: () => void }, url: string) => {
event.preventDefault?.();
void runPromise(deepLinks.handleUrl(url));
});

// Packaged builds own the OS protocol handler. Skip in development so a
// local electron binary does not replace the installed t3code handler.
if (environment.isPackaged && !environment.isDevelopment) {
yield* electronApp.setAsDefaultProtocolClient(DesktopDeepLinks.DESKTOP_EXTERNAL_PROTOCOL);
}

// Initial argv may already contain a deep link (direct CLI invocation or
// protocol launch on Linux/Windows).
yield* deepLinks.handleArgv(process.argv);
}).pipe(Effect.withSpan("desktop.clerk.configure")),
});
});
Expand Down
Loading
Loading