| 1 | import { createClerkBridge } from "@clerk/electron"; |
| 2 | import * as NodeURL from "node:url"; |
| 3 | import { storage } from "@clerk/electron/storage"; |
| 4 | import * as Context from "effect/Context"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as Scope from "effect/Scope"; |
| 10 | |
| 11 | import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/codexAuthHandoff"; |
| 12 | import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts"; |
| 13 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 14 | import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; |
| 15 | import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; |
| 16 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 17 | import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; |
| 18 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 19 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; |
| 20 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 21 | import * as DesktopUserData from "./DesktopUserData.ts"; |
| 22 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 23 | import * as DesktopWebLinks from "./DesktopWebLinks.ts"; |
| 24 | |
| 25 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; |
| 26 | |
| 27 | export 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 | |
| 40 | export 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 | |
| 53 | export 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 | |
| 67 | function 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 | |
| 80 | export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHostname( |
| 81 | typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" |
| 82 | ? undefined |
| 83 | : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, |
| 84 | ); |
| 85 | |
| 86 | function 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. */ |
| 98 | export 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 | |
| 241 | export const layer = Layer.effect(DesktopClerk, make); |