Skip to content
Source
Sign inSign up
Open History to see the last commit

DesktopClerk.ts

262 lines9.8 KB

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 { readPairingAppLink } from "@t3tools/shared/remote";
17import * as HostProcess from "@t3tools/shared/HostProcess";
18import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
19import * as ElectronApp from "../electron/ElectronApp.ts";
20import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
21import * as ElectronWindow from "../electron/ElectronWindow.ts";
22import * as DesktopUserData from "./DesktopUserData.ts";
23import * as DesktopEnvironment from "./DesktopEnvironment.ts";
24import { openClerkTokenStorage } from "./ClerkTokenStore.ts";
25import * as DesktopWebLinks from "./DesktopWebLinks.ts";
26​
27declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
28​
29export class DesktopClerkBridgeInitializationError extends Schema.TaggedError<DesktopClerkBridgeInitializationError>()(
30 "DesktopClerkBridgeInitializationError",
31 {
32 stateDir: Schema.String,
33 isDevelopment: Schema.Boolean,
34 cause: Schema.Defect(),
35 },
36) {
37 override get message(): string {
38 return `Failed to initialize the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`;
39 }
41​
42export class DesktopClerkBridgeCleanupError extends Schema.TaggedError<DesktopClerkBridgeCleanupError>()(
43 "DesktopClerkBridgeCleanupError",
44 {
45 stateDir: Schema.String,
46 isDevelopment: Schema.Boolean,
47 cause: Schema.Defect(),
48 },
49) {
50 override get message(): string {
51 return `Failed to clean up the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`;
52 }
54​
55export class DesktopClerk extends Context.Service<
56 DesktopClerk,
57 {
58 readonly configure: Effect.Effect<
59 void,
60 never,
61 | ElectronApp.ElectronApp
62 | ElectronWindow.ElectronWindow
63 | DesktopWebLinks.DesktopWebLinks
64 | Scope.Scope
65 >;
66 }
67>()("@t3tools/desktop/app/DesktopClerk") {}
68​
69function resolveDesktopClerkFrontendApiHostname(
70 publishableKey: string | undefined,
71): string | undefined {
72 const normalizedKey = publishableKey?.trim();
73 if (!normalizedKey) return undefined;
74​
75 try {
76 return clerkFrontendApiHostnameFromPublishableKey(normalizedKey);
77 } catch {
78 return undefined;
79 }
81​
82export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHostname(
83 typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined"
84 ? undefined
85 : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__,
86);
87​
88function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean, userDataPath: string) {
89 return createClerkBridge({
90 storage: openClerkTokenStorage(
91 stateDir,
92 (directory) => storage({ path: directory }),
93 userDataPath,
94 ),
95 manageSingleInstanceLock: false,
96 passkeys: true,
97 renderer: {
98 scheme: ElectronProtocol.getDesktopScheme(isDevelopment),
99 host: ElectronProtocol.DESKTOP_HOST,
100 },
101 });
103​
104/** @public */
105export const make = Effect.gen(function* () {
106 const environment = yield* DesktopEnvironment.DesktopEnvironment;
107 const electronApp = yield* ElectronApp.ElectronApp;
108 const shell = yield* ElectronShell.ElectronShell;
109​
110 // Must not yield: the bridge registers a scheme Electron rejects once ready.
111 const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment);
112 yield* electronApp.setPath("userData", userDataPath);
113​
114 // Clerk skips the profile lock on macOS. Interrupt before runtime construction because quit is asynchronous.
115 if (!(yield* electronApp.requestSingleInstanceLock)) {
116 yield* electronApp.quit;
117 return yield* Effect.interrupt;
118 }
119 // Chromium opens these caches at the first event-loop turn, before ready.
120 yield* DesktopUserData.clearStaleGpuCaches({
121 userDataPath,
122 electronVersion: process.versions.electron,
123 });
124​
125 const pendingSecondInstances: Array<readonly string[]> = [];
126 let handleSecondInstance = (argv: readonly string[]) => {
127 pendingSecondInstances.push(argv);
128 };
129 yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {
130 handleSecondInstance(argv);
131 });
132 // macOS hands a launch link to open-url, which can fire before configure runs.
133 const pendingOpenUrls: Array<string> = [];
134 let handleOpenUrl = (url: string) => {
135 pendingOpenUrls.push(url);
136 return true;
137 };
138 yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => {
139 if (handleOpenUrl(url)) event.preventDefault();
140 });
141​
142 yield* Effect.acquireRelease(
143 Effect.try({
144 try: () =>
145 createDesktopClerkBridge(environment.stateDir, environment.isDevelopment, userDataPath),
146 catch: (cause) =>
147 new DesktopClerkBridgeInitializationError({
148 stateDir: environment.stateDir,
149 isDevelopment: environment.isDevelopment,
150 cause,
151 }),
152 }),
153 (bridge) =>
154 Effect.try({
155 try: () => bridge.cleanup(),
156 catch: (cause) =>
157 new DesktopClerkBridgeCleanupError({
158 stateDir: environment.stateDir,
159 isDevelopment: environment.isDevelopment,
160 cause,
161 }),
162 }).pipe(Effect.orDie),
163 );
164​
165 return DesktopClerk.of({
166 configure: Effect.gen(function* () {
167 const electronApp = yield* ElectronApp.ElectronApp;
168 const electronWindow = yield* ElectronWindow.ElectronWindow;
169 const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
170 const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
171 const runPromise = Effect.runPromiseWith(context);
172​
173 const startProviderAuthHandoff = (value: string | undefined) => {
174 if (!value) return false;
175 const request = readCodexAuthHandoff(value, environment.isDevelopment);
176 if (!request) return false;
177 void runPromise(
178 Effect.gen(function* () {
179 yield* electronApp.whenReady;
180 yield* Effect.tryPromise({
181 try: (signal) =>
182 receiveCodexAuthCallback(
183 request.authorizationUrl,
184 (url) => runPromise(shell.openExternal(url)),
185 (callbackUrl) => codexAuthDeliveryUrl(request, callbackUrl),
186 signal,
187 ),
188 catch: () =>
189 new CodexAuthCallbackError({
190 detail:
191 "Could not receive hosted web ChatGPT sign-in. Retry or use the redirect URL in the web app.",
192 }),
193 });
194 }).pipe(
195 Effect.catch(() => Effect.logWarning("Could not complete ChatGPT desktop handoff.")),
196 ),
197 );
198 return true;
199 };
200 const resumeProviderAuth = (value: string | undefined) => {
201 const destination = providerAuthReturnUrl(value);
202 const expectedOrigin = `${ElectronProtocol.getDesktopScheme(environment.isDevelopment)}://app`;
203 if (!destination?.startsWith(`${expectedOrigin}/`)) return false;
204 void runPromise(
205 Effect.gen(function* () {
206 const mainWindow = yield* electronWindow.currentMainOrFirst;
207 if (Option.isNone(mainWindow)) return;
208 yield* Effect.promise(() => mainWindow.value.loadURL(destination));
209 yield* electronWindow.reveal(mainWindow.value);
210 }).pipe(
211 Effect.catchCause((cause) =>
212 Effect.logWarning("Could not return to provider setup", cause),
213 ),
214 ),
215 );
216 return true;
217 };
218 const openAppLink = (value: string | undefined) => {
219 if (!value || (promptLinkAppPath(value) === null && readPairingAppLink(value) === null))
220 return false;
221 void runPromise(webLinks.receive(value));
222 return true;
223 };
224 const args = yield* HostProcess.Arguments;
225 args.some((value) => startProviderAuthHandoff(value) || openAppLink(value));
226 const openWebLink = (url: string) => {
227 if (!DesktopWebLinks.isWebLink(url)) return false;
228 void runPromise(webLinks.receive(url));
229 return true;
230 };
231 handleOpenUrl = (url) =>
232 startProviderAuthHandoff(url) ||
233 resumeProviderAuth(url) ||
234 openWebLink(url) ||
235 openAppLink(url);
236 for (const url of pendingOpenUrls.splice(0)) handleOpenUrl(url);
237 yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => {
238 if (!DesktopWebLinks.isWebPageFile(path)) return;
239 event.preventDefault();
240 void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href));
241 });
242 handleSecondInstance = (argv) => {
243 if (
244 argv?.some(
245 (value) =>
246 startProviderAuthHandoff(value) || resumeProviderAuth(value) || openAppLink(value),
247 )
248 )
249 return;
250 void runPromise(
251 Effect.gen(function* () {
252 const mainWindow = yield* electronWindow.currentMainOrFirst;
253 if (Option.isSome(mainWindow)) yield* electronWindow.reveal(mainWindow.value);
254 }),
255 );
256 };
257 for (const argv of pendingSecondInstances.splice(0)) handleSecondInstance(argv);
258 }).pipe(Effect.withSpan("desktop.clerk.configure")),
259 });
260});
261​
262export const layer = Layer.effect(DesktopClerk, make);