1import { createClerkBridge } from "@clerk/electron";
2import * as NodeURL from "node:url";
3import { storage } from "@clerk/electron/storage";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import * as Scope from "effect/Scope";
10
11import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/codexAuthHandoff";
12import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts";
13import * as ElectronShell from "../electron/ElectronShell.ts";
14import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";
15import { promptLinkAppPath } from "@t3tools/shared/promptLinks";
16import * as HostProcess from "@t3tools/shared/HostProcess";
17import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
18import * as ElectronApp from "../electron/ElectronApp.ts";
19import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
20import * as ElectronWindow from "../electron/ElectronWindow.ts";
21import * as DesktopUserData from "./DesktopUserData.ts";
22import * as DesktopEnvironment from "./DesktopEnvironment.ts";
23import * as DesktopWebLinks from "./DesktopWebLinks.ts";
24
25declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
26
27export class DesktopClerkBridgeInitializationError extends Schema.TaggedError<DesktopClerkBridgeInitializationError>()(
28 "DesktopClerkBridgeInitializationError",
29 {
30 stateDir: Schema.String,
31 isDevelopment: Schema.Boolean,
32 cause: Schema.Defect(),
33 },
34) {
35 override get message(): string {
36 return `Failed to initialize the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`;
37 }
38}
39
40export class DesktopClerkBridgeCleanupError extends Schema.TaggedError<DesktopClerkBridgeCleanupError>()(
41 "DesktopClerkBridgeCleanupError",
42 {
43 stateDir: Schema.String,
44 isDevelopment: Schema.Boolean,
45 cause: Schema.Defect(),
46 },
47) {
48 override get message(): string {
49 return `Failed to clean up the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`;
50 }
51}
52
53export class DesktopClerk extends Context.Service<
54 DesktopClerk,
55 {
56 readonly configure: Effect.Effect<
57 void,
58 never,
59 | ElectronApp.ElectronApp
60 | ElectronWindow.ElectronWindow
61 | DesktopWebLinks.DesktopWebLinks
62 | Scope.Scope
63 >;
64 }
65>()("@t3tools/desktop/app/DesktopClerk") {}
66
67function resolveDesktopClerkFrontendApiHostname(
68 publishableKey: string | undefined,
69): string | undefined {
70 const normalizedKey = publishableKey?.trim();
71 if (!normalizedKey) return undefined;
72
73 try {
74 return clerkFrontendApiHostnameFromPublishableKey(normalizedKey);
75 } catch {
76 return undefined;
77 }
78}
79
80export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHostname(
81 typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined"
82 ? undefined
83 : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__,
84);
85
86function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) {
87 return createClerkBridge({
88 storage: storage({ path: stateDir }),
89 passkeys: true,
90 renderer: {
91 scheme: ElectronProtocol.getDesktopScheme(isDevelopment),
92 host: ElectronProtocol.DESKTOP_HOST,
93 },
94 });
95}
96
97/** @public Service construction is part of the canonical Effect module API. */
98export const make = Effect.gen(function* () {
99 const environment = yield* DesktopEnvironment.DesktopEnvironment;
100 const electronApp = yield* ElectronApp.ElectronApp;
101 const shell = yield* ElectronShell.ElectronShell;
102
103 // The SDK bridge acquires Electron's profile-scoped single-instance lock.
104 // Must not yield: the bridge registers a scheme Electron rejects once ready.
105 const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment);
106 yield* electronApp.setPath("userData", userDataPath);
107
108 const bridge = yield* Effect.acquireRelease(
109 Effect.try({
110 try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment),
111 catch: (cause) =>
112 new DesktopClerkBridgeInitializationError({
113 stateDir: environment.stateDir,
114 isDevelopment: environment.isDevelopment,
115 cause,
116 }),
117 }),
118 (bridge) =>
119 Effect.try({
120 try: () => bridge.cleanup(),
121 catch: (cause) =>
122 new DesktopClerkBridgeCleanupError({
123 stateDir: environment.stateDir,
124 isDevelopment: environment.isDevelopment,
125 cause,
126 }),
127 }).pipe(Effect.orDie),
128 );
129
130 return DesktopClerk.of({
131 configure: Effect.gen(function* () {
132 const electronApp = yield* ElectronApp.ElectronApp;
133 const electronWindow = yield* ElectronWindow.ElectronWindow;
134 const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
135 const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
136 const runPromise = Effect.runPromiseWith(context);
137
138 // The SDK bridge holds Electron's single-instance lock (acquired at
139 // bridge creation) so OAuth deep-link callbacks on Windows/Linux are
140 // forwarded to the running app. In a secondary instance the bridge has
141 // already begun quitting the app; app.quit() is asynchronous, so stop
142 // bootstrap here before whenReady can fire.
143 if (!bridge.isPrimaryInstance) {
144 yield* electronApp.quit;
145 return yield* Effect.interrupt;
146 }
147
148 const startProviderAuthHandoff = (value: string | undefined) => {
149 if (!value) return false;
150 const request = readCodexAuthHandoff(value, environment.isDevelopment);
151 if (!request) return false;
152 void runPromise(
153 Effect.gen(function* () {
154 yield* electronApp.whenReady;
155 yield* Effect.tryPromise({
156 try: () =>
157 receiveCodexAuthCallback(
158 request.authorizationUrl,
159 (url) => runPromise(shell.openExternal(url)),
160 (callbackUrl) => codexAuthDeliveryUrl(request, callbackUrl),
161 ),
162 catch: () =>
163 new CodexAuthCallbackError({
164 detail:
165 "Could not receive hosted web ChatGPT sign-in. Retry or use the redirect URL in the web app.",
166 }),
167 });
168 }).pipe(
169 Effect.catch(() => Effect.logWarning("Could not complete ChatGPT desktop handoff.")),
170 ),
171 );
172 return true;
173 };
174 const resumeProviderAuth = (value: string | undefined) => {
175 const destination = providerAuthReturnUrl(value);
176 const expectedOrigin = `${ElectronProtocol.getDesktopScheme(environment.isDevelopment)}://app`;
177 if (!destination?.startsWith(`${expectedOrigin}/`)) return false;
178 void runPromise(
179 Effect.gen(function* () {
180 const mainWindow = yield* electronWindow.currentMainOrFirst;
181 if (Option.isNone(mainWindow)) return;
182 yield* Effect.promise(() => mainWindow.value.loadURL(destination));
183 yield* electronWindow.reveal(mainWindow.value);
184 }).pipe(
185 Effect.catchCause((cause) =>
186 Effect.logWarning("Could not return to provider setup", cause),
187 ),
188 ),
189 );
190 return true;
191 };
192 // Prompt links and thread links (`t3code://new?…`, `t3code://threads/…`) reach the
193 // renderer through the same queue as web links; it routes them in the app.
194 const openAppLink = (value: string | undefined) => {
195 if (!value || promptLinkAppPath(value) === null) return false;
196 void runPromise(webLinks.receive(value));
197 return true;
198 };
199 const args = yield* HostProcess.Arguments;
200 args.some((value) => startProviderAuthHandoff(value) || openAppLink(value));
201 // As the default browser, macOS hands T3 Code every web link through the same event.
202 const openWebLink = (url: string) => {
203 if (!DesktopWebLinks.isWebLink(url)) return false;
204 void runPromise(webLinks.receive(url));
205 return true;
206 };
207 yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => {
208 if (
209 startProviderAuthHandoff(url) ||
210 resumeProviderAuth(url) ||
211 openWebLink(url) ||
212 openAppLink(url)
213 )
214 event.preventDefault();
215 });
216 // A browser opens HTML files too, which macOS hands over by path.
217 yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => {
218 if (!DesktopWebLinks.isWebPageFile(path)) return;
219 event.preventDefault();
220 void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href));
221 });
222 yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {
223 if (
224 argv?.some(
225 (value) =>
226 startProviderAuthHandoff(value) || resumeProviderAuth(value) || openAppLink(value),
227 )
228 )
229 return;
230 void runPromise(
231 Effect.gen(function* () {
232 const mainWindow = yield* electronWindow.currentMainOrFirst;
233 if (Option.isSome(mainWindow)) yield* electronWindow.reveal(mainWindow.value);
234 }),
235 );
236 });
237 }).pipe(Effect.withSpan("desktop.clerk.configure")),
238 });
239});
240
241export const layer = Layer.effect(DesktopClerk, make);