From fd073caa8e3d2355df304c001312c288adfbaefe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 10:57:24 -0700 Subject: [PATCH 1/3] fix(desktop): bump Clerk Electron SDK to 0.0.24 and register t3code:// scheme on Linux @clerk/electron 0.0.24 holds Electron's single-instance lock so OAuth deep-link callbacks reach the running app instead of being swallowed by a second instance. Defer to the SDK's lock and gate bootstrap on the bridge's isPrimaryInstance instead of acquiring our own lock after bridge creation. Register the t3code/t3code-dev schemes in the Linux electron-builder config so the generated .desktop entry carries MimeType=x-scheme-handler/t3code; and %U in Exec, letting browsers hand OAuth callbacks to the app. Also aligns the whole Clerk catalog (backend, clerk-js, expo, react, shared) so no internal Clerk package is duplicated. Co-Authored-By: Claude Fable 5 --- .../src/app/DesktopAppIdentity.test.ts | 1 - apps/desktop/src/app/DesktopClerk.test.ts | 63 +++++++++++- apps/desktop/src/app/DesktopClerk.ts | 9 +- apps/desktop/src/app/DesktopLifecycle.test.ts | 1 - apps/desktop/src/electron/ElectronApp.test.ts | 3 - apps/desktop/src/electron/ElectronApp.ts | 2 - .../DesktopTelemetryPublisher.test.ts | 1 - .../src/window/DesktopApplicationMenu.test.ts | 1 - pnpm-lock.yaml | 96 +++++++++---------- pnpm-workspace.yaml | 24 ++--- scripts/build-desktop-artifact.test.ts | 5 + scripts/build-desktop-artifact.ts | 9 ++ 12 files changed, 142 insertions(+), 73 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 2eb540f0526..6c5385e70a6 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -54,7 +54,6 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => calls.setAboutPanelOptions.push(options); }), setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 9b5ed56d1f3..652575c53d6 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -22,6 +22,9 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); +import * as Exit from "effect/Exit"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -56,7 +59,7 @@ describe("DesktopClerk", () => { it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup }); + createClerkBridgeMock.mockReturnValue({ cleanup, isPrimaryInstance: true }); return Effect.gen(function* () { yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); @@ -124,11 +127,67 @@ describe("DesktopClerk", () => { }); }); + it.effect("registers the second-instance handler in the primary instance", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + 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)); + + assert.isTrue(Exit.isSuccess(exit)); + assert.equal(quit.mock.calls.length, 0); + assert.deepEqual(registeredEvents, ["second-instance"]); + }).pipe( + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + + 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)); + + assert.isTrue(Exit.hasInterrupts(exit)); + assert.equal(quit.mock.calls.length, 1); + assert.deepEqual(registeredEvents, []); + }).pipe( + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + it.each([ { isDevelopment: true, scheme: "t3code-dev" }, { isDevelopment: false, scheme: "t3code" }, ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn() }; + const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue(bridge); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0e283f8dd0c..538d60141e8 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -84,7 +84,7 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* Effect.acquireRelease( + const bridge = yield* Effect.acquireRelease( Effect.try({ try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), catch: (cause) => @@ -113,7 +113,12 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - if (!(yield* electronApp.requestSingleInstanceLock)) { + // 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; } diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index e5ce72f8e48..978e000a7f5 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -29,7 +29,6 @@ describe("DesktopLifecycle", () => { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 077b343959c..ac14f56ad1a 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -14,7 +14,6 @@ const { quitMock, relaunchMock, removeListenerMock, - requestSingleInstanceLockMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -35,7 +34,6 @@ const { quitMock: vi.fn(), relaunchMock: vi.fn(), removeListenerMock: vi.fn(), - requestSingleInstanceLockMock: vi.fn(() => true), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -67,7 +65,6 @@ vi.mock("electron", () => ({ quit: quitMock, relaunch: relaunchMock, removeListener: removeListenerMock, - requestSingleInstanceLock: requestSingleInstanceLockMock, runningUnderARM64Translation: false, setAboutPanelOptions: setAboutPanelOptionsMock, setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 5f8052f902d..73323617195 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -56,7 +56,6 @@ export class ElectronApp extends Context.Service< options: Electron.AboutPanelOptionsOptions, ) => Effect.Effect; readonly setAppUserModelId: (id: string) => Effect.Effect; - readonly requestSingleInstanceLock: Effect.Effect; readonly getAppMetrics: Effect.Effect>; readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; readonly setAsDefaultProtocolClient: ( @@ -153,7 +152,6 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.app.setAppUserModelId(id); }), - requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), isDefaultProtocolClient: (protocol) => Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 475be3da151..36cdcb50b6b 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -34,7 +34,6 @@ function makeElectronAppLayer( setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.sync(() => { onMetricsRead(); return metrics; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 34fc4447146..f04a49f82af 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -39,7 +39,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 816846ab529..f5c6c87895c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,19 +35,19 @@ catalogs: version: 0.2.2 overrides: - '@clerk/backend': 3.13.0 - '@clerk/clerk-js': 6.25.7 + '@clerk/backend': 3.14.0 + '@clerk/clerk-js': 6.25.12 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' '@clerk/clerk-js>@solana/wallet-adapter-react': '-' '@clerk/clerk-js>@solana/wallet-standard': '-' '@clerk/clerk-js>@wallet-standard/core': '-' - '@clerk/electron': 0.0.18 + '@clerk/electron': 0.0.24 '@clerk/electron-passkeys': 0.0.3 - '@clerk/expo': 4.0.2 - '@clerk/react': 6.12.7 - '@clerk/shared': 4.25.7 + '@clerk/expo': 4.1.2 + '@clerk/react': 6.12.9 + '@clerk/shared': 4.25.9 '@effect/atom-react': 4.0.0-beta.102 '@effect/platform-bun': 4.0.0-beta.102 '@effect/platform-node': 4.0.0-beta.102 @@ -111,8 +111,8 @@ importers: apps/desktop: dependencies: '@clerk/electron': - specifier: 0.0.18 - version: 0.0.18(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.24 + version: 0.0.24(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -197,8 +197,8 @@ importers: specifier: ^0.7.1 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: 4.0.2 - version: 4.0.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + specifier: 4.1.2 + version: 4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(react@19.2.3)(scheduler@0.27.0) @@ -514,11 +514,11 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': - specifier: 0.0.18 - version: 0.0.18(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.24 + version: 0.0.24(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: 6.12.7 - version: 6.12.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.12.9 + version: 6.12.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -668,8 +668,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.13.0 - version: 3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.14.0 + version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -1695,12 +1695,12 @@ packages: resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.13.0': - resolution: {integrity: sha512-kYOgeR48iXYypudpYITE0L2yxi4TeFXU+ISAOULlVgOcurw8qnm1/JEuK6Mr2IFUbRS9hcHk1HVO0Ceo0XmL8Q==} + '@clerk/backend@3.14.0': + resolution: {integrity: sha512-WsphTvDFHDuQilKI7dyVE5qmt7USu8qSrujAq6SqpEHbVFfNBfINDNuzxlF9M2skOTfHFfgiTE8Jjb1egIBGLg==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.25.7': - resolution: {integrity: sha512-GOnbEUzPKAdsdCodZsHhku2YloBBuwLGx5UP0VeezUj73wKLwlSYMlVPuntsJoY7c8+lVrMCY9LQWYVZ7VMLBw==} + '@clerk/clerk-js@6.25.12': + resolution: {integrity: sha512-N91mFKenMF5e+LRQ0NsqFb8bw+icbwnF8MzjzS4cux72xG4Gekh772tiLIetbURe44Srgjw5lj8jkWbt+yH/FA==} engines: {node: '>=20.9.0'} '@clerk/electron-passkeys-darwin-arm64@0.0.3': @@ -1727,8 +1727,8 @@ packages: resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.18': - resolution: {integrity: sha512-0lWiITlTxCviglPfPSu+uhERnQyXroiWrSMRpZcEO3FgBt3bnrpfgZUrXW8iaLoUYJrFP2byF7bHJPRwEPVXEw==} + '@clerk/electron@0.0.24': + resolution: {integrity: sha512-xORO51KLzCqmRQ4IVsa+Amp1M5NjG28QnvsaRBq1v4CUEjH8vAXDKCPiPBKAE+HuQB6HUhzYmzUkyTw2FcDonw==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/electron-passkeys': 0.0.3 @@ -1744,8 +1744,8 @@ packages: react-dom: optional: true - '@clerk/expo@4.0.2': - resolution: {integrity: sha512-afBdV2bLRM55p5L7FoAGV/wCamLxXusjIBPmNo+TwAYabU0oUokMsm+0++xWDA0nxzMt/O08Oj+xAGEtE+Qvxg==} + '@clerk/expo@4.1.2': + resolution: {integrity: sha512-ZyGozbbizcGwEyK0CEXsPHnHnQoaa2CbTsXAqWBygzKEZyFjLjCHwQsQBKj6WgjAgn8mtw2ByIP9qLpItZffgw==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/expo-google-signin': '>=0.1.0' @@ -1783,15 +1783,15 @@ packages: react-dom: optional: true - '@clerk/react@6.12.7': - resolution: {integrity: sha512-K8CK0tS7My/3RT5sAIJTjYgnH0xU+K4Jdnhi76xQdAY4agD/M07BbrBMWXEE0xUEDst2PqPfX660V1LRLOsxOQ==} + '@clerk/react@6.12.9': + resolution: {integrity: sha512-lQ1UOJhHGDweLCDf4IgT5r3rgkch1Gx21hNqK2/kOohJrJd/XM89fq2F7VK9Kg/Byc14TsfCh64Dm8ej2MyRWw==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.25.7': - resolution: {integrity: sha512-FfBbDeFkxwDuz/2YcVs4DhyZ0VxnqNtdbO0Tn/lkLAiDvFFspWw+PDYqo7LQiBymEQaOQkb094dX21WWnZbmlA==} + '@clerk/shared@4.25.9': + resolution: {integrity: sha512-nw3maGzqPrwmUKjGffOM9Zvw24IyOllq0TkQGzsTfQX312WLu2u5CNvjKntMb6Ok6kXa1Zdc77zmPLn1/EAIFA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -11512,18 +11512,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.13.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.25.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11538,9 +11538,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.25.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11574,11 +11574,11 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.0.18(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.24(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/clerk-js': 6.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/react': 6.12.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/shared': 4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/clerk-js': 6.25.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.12.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) electron: 41.5.0 react: 19.2.6 tslib: 2.8.1 @@ -11587,11 +11587,11 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@4.0.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': + '@clerk/expo@4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': dependencies: - '@clerk/clerk-js': 6.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.12.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.25.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.12.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@expo/config-plugins': 56.0.9(typescript@6.0.3) base-64: 1.0.0 expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -11610,21 +11610,21 @@ snapshots: - supports-color - typescript - '@clerk/react@6.12.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.12.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.12.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.12.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.25.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.25.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11634,7 +11634,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.25.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.25.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d41e50e8784..79736c894e1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,13 +23,13 @@ allowBuilds: workerd: false catalog: - "@clerk/backend": 3.13.0 - "@clerk/clerk-js": 6.25.7 - "@clerk/electron": 0.0.18 + "@clerk/backend": 3.14.0 + "@clerk/clerk-js": 6.25.12 + "@clerk/electron": 0.0.24 "@clerk/electron-passkeys": 0.0.3 - "@clerk/expo": 4.0.2 - "@clerk/react": 6.12.7 - "@clerk/shared": 4.25.7 + "@clerk/expo": 4.1.2 + "@clerk/react": 6.12.9 + "@clerk/shared": 4.25.9 "@effect/atom-react": 4.0.0-beta.102 "@effect/openapi-generator": 4.0.0-beta.102 "@effect/platform-bun": 4.0.0-beta.102 @@ -52,12 +52,12 @@ catalog: yaml: ^2.9.0 minimumReleaseAgeExclude: - - "@clerk/backend@3.13.0" - - "@clerk/clerk-js@6.25.7" - - "@clerk/electron@0.0.18" - - "@clerk/expo@4.0.2" - - "@clerk/react@6.12.7" - - "@clerk/shared@4.25.7" + - "@clerk/backend@3.14.0" + - "@clerk/clerk-js@6.25.12" + - "@clerk/electron@0.0.24" + - "@clerk/expo@4.1.2" + - "@clerk/react@6.12.9" + - "@clerk/shared@4.25.9" - "@distilled.cloud/aws@0.30.2" - "@distilled.cloud/axiom@0.30.2" - "@distilled.cloud/cloudflare@0.30.2" diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 64bb6c0617c..7d2b7410a9e 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -349,6 +349,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.notProperty(mac, "asarUnpack"); assert.notProperty(linux, "asarUnpack"); assert.deepStrictEqual(win.asarUnpack, WINDOWS_ASAR_UNPACK); + // Linux must register the renderer schemes so the generated .desktop + // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links. + assert.deepStrictEqual((linux.linux as Record).protocols, [ + { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, + ]); for (const config of [mac, linux, win]) { assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); assert.deepStrictEqual(config.files, DESKTOP_FILE_EXCLUSIONS); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 9687e76a1dc..a3c99969256 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1589,6 +1589,15 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( executableName: "t3code", icon: "icons", category: "Development", + // electron-builder turns these into MimeType=x-scheme-handler/; + // in the .desktop entry (Exec already gets %U), so browsers can hand + // t3code:// OAuth callbacks to the app. + protocols: [ + { + name: "T3 Code", + schemes: ["t3code", "t3code-dev"], + }, + ], desktop: { entry: { StartupWMClass: "t3code", From 81fe1ada401d9a8b89e6364d807de0c66865f451 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 11:12:19 -0700 Subject: [PATCH 2/3] fix(desktop): set userData path before the Clerk bridge takes the instance lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron scopes the single-instance lock to userData and creates that directory on acquisition. Since @clerk/electron 0.0.24 takes the lock at bridge creation — before startup ran setPath("userData") — the default productName-derived directory "T3 Code (Alpha)" was created, which the legacy-install detection in resolveUserDataPath then preferred over the real t3code directory on fresh and migrated installs. Resolve and set the real userData path in the DesktopClerk layer before creating the bridge, sharing DesktopAppIdentity's resolution logic. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/app/DesktopAppIdentity.ts | 45 +++++++++++++--------- apps/desktop/src/app/DesktopClerk.test.ts | 34 ++++++++++++++-- apps/desktop/src/app/DesktopClerk.ts | 12 ++++++ 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 385e694338d..760b5b0ee75 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -45,6 +45,28 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; +export const resolveUserDataPath = Effect.fn("desktop.appIdentity.resolveUserDataPath")(function* ( + environment: DesktopEnvironment.DesktopEnvironment["Service"], +) { + const fileSystem = yield* FileSystem.FileSystem; + const legacyPath = environment.path.join( + environment.appDataDirectory, + environment.legacyUserDataDirName, + ); + const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( + Effect.mapError( + (cause) => + new DesktopUserDataPathResolutionError({ + legacyPath, + cause, + }), + ), + ); + return legacyPathExists + ? legacyPath + : environment.path.join(environment.appDataDirectory, environment.userDataDirName); +}); + export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; @@ -90,24 +112,9 @@ export const make = Effect.gen(function* () { return commitHash; }); - const resolveUserDataPath = Effect.gen(function* () { - const legacyPath = environment.path.join( - environment.appDataDirectory, - environment.legacyUserDataDirName, - ); - const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( - Effect.mapError( - (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, - cause, - }), - ), - ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); - }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); + const userDataPath = resolveUserDataPath(environment).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; @@ -136,7 +143,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.appIdentity.configure")); return DesktopAppIdentity.of({ - resolveUserDataPath, + resolveUserDataPath: userDataPath, configure, }); }); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 652575c53d6..2f61ca909ae 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -23,19 +23,37 @@ vi.mock("@clerk/electron/storage", () => ({ })); import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true) => { +const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, + appDataDirectory: "/tmp/app-data", + userDataDirName: isDevelopment ? "t3code-dev" : "t3code", + legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + const electronApp = { + setPath: (name: string, value: string) => + Effect.sync(() => { + events.push(`setPath:${name}:${value}`); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + return DesktopClerk.layer.pipe( - Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), + Layer.succeed(ElectronApp.ElectronApp, electronApp), + FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), + ), + ), ); }; @@ -58,11 +76,15 @@ describe("DesktopClerk", () => { it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); + const events: string[] = []; storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup, isPrimaryInstance: true }); + createClerkBridgeMock.mockImplementation(() => { + events.push("createClerkBridge"); + return { cleanup, isPrimaryInstance: true }; + }); return Effect.gen(function* () { - yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer(true, events))); assert.deepEqual(createClerkBridgeMock.mock.calls, [ [ @@ -74,6 +96,10 @@ describe("DesktopClerk", () => { ], ]); 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"]); storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 538d60141e8..873a66196fa 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -11,6 +11,7 @@ import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/rela import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -84,6 +85,17 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + + // Electron scopes the single-instance lock to the userData directory and + // creates that directory when the lock is acquired. The SDK bridge takes + // the lock at creation, so userData must already point at the real + // directory here — under the default productName-derived path, acquiring + // the lock would create "T3 Code (Alpha)" and make the legacy-install + // detection in resolveUserDataPath match on fresh installs. + const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath(environment); + yield* electronApp.setPath("userData", userDataPath); + const bridge = yield* Effect.acquireRelease( Effect.try({ try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), From 702ae3ff94bde7509a15ee40d59e4bd49626b7ec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 11:37:31 -0700 Subject: [PATCH 3/3] refactor(desktop): acquire DesktopEnvironment from context in resolveUserDataPath Follow the Effect service convention: the shared helper reads DesktopEnvironment and FileSystem from the environment instead of taking the service instance as a parameter, and DesktopAppIdentity provides its captured context only at the service boundary. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/app/DesktopAppIdentity.ts | 13 +++++++------ apps/desktop/src/app/DesktopClerk.ts | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 760b5b0ee75..0be55d633e6 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -45,9 +45,8 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -export const resolveUserDataPath = Effect.fn("desktop.appIdentity.resolveUserDataPath")(function* ( - environment: DesktopEnvironment.DesktopEnvironment["Service"], -) { +export const resolveUserDataPath = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const legacyPath = environment.path.join( environment.appDataDirectory, @@ -65,7 +64,7 @@ export const resolveUserDataPath = Effect.fn("desktop.appIdentity.resolveUserDat return legacyPathExists ? legacyPath : environment.path.join(environment.appDataDirectory, environment.userDataDirName); -}); +}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; @@ -112,8 +111,10 @@ export const make = Effect.gen(function* () { return commitHash; }); - const userDataPath = resolveUserDataPath(environment).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), + const userDataPath = resolveUserDataPath.pipe( + Effect.provide( + yield* Effect.context(), + ), ); const configure = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 873a66196fa..9611dc083d2 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -93,7 +93,7 @@ export const make = Effect.gen(function* () { // directory here — under the default productName-derived path, acquiring // the lock would create "T3 Code (Alpha)" and make the legacy-install // detection in resolveUserDataPath match on fresh installs. - const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath(environment); + const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); const bridge = yield* Effect.acquireRelease(