diff --git a/.changeset/electron-bridge-fixes.md b/.changeset/electron-bridge-fixes.md new file mode 100644 index 00000000000..e18df5a0dca --- /dev/null +++ b/.changeset/electron-bridge-fixes.md @@ -0,0 +1,7 @@ +--- +'@clerk/electron': minor +--- + +- OAuth sign-in without the `renderer` option in `createClerkBridge()` now throws a Clerk error that asks for it. Previously it failed with Electron's "No handler registered" error. +- Telemetry now reports `@clerk/electron` instead of `@clerk/react`. +- Add a `@clerk/electron/react/experimental` entry point that re-exports `@clerk/react/experimental`. diff --git a/packages/electron/package.json b/packages/electron/package.json index c5a3325bc28..b05827c841d 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -64,6 +64,16 @@ "default": "./dist/cjs/react/index.js" } }, + "./react/experimental": { + "import": { + "types": "./dist/types/react/experimental.d.ts", + "default": "./dist/esm/react/experimental.js" + }, + "require": { + "types": "./dist/types/react/experimental.d.ts", + "default": "./dist/cjs/react/experimental.js" + } + }, "./passkeys": { "import": { "types": "./dist/types/passkeys/index.d.ts", diff --git a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts index d7617f08d99..ac0193d3531 100644 --- a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts +++ b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts @@ -96,7 +96,8 @@ describe('createClerkBridge', () => { it('sets up token persistence IPC handlers with the provided storage', () => { createClerkBridge({ storage }); - expect(ipcMain.handle).toHaveBeenCalledTimes(3); + const channels = vi.mocked(ipcMain.handle).mock.calls.map(([channel]) => channel); + expect(channels).toEqual(expect.arrayContaining(Object.values(TOKEN_CACHE_CHANNELS))); }); it('keeps the platform details when setting the app user-agent fallback', () => { @@ -230,7 +231,7 @@ describe('createClerkBridge', () => { clerk.cleanup(); - expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); + expect(ipcMain.removeHandler).toHaveBeenCalledTimes(5); }); it('does not register passkey IPC handlers by default', () => { @@ -256,7 +257,32 @@ describe('createClerkBridge', () => { clerk.cleanup(); - expect(ipcMain.removeHandler).toHaveBeenCalledTimes(6); + expect(ipcMain.removeHandler).toHaveBeenCalledTimes(8); + }); + + it('registers OAuth transport handlers that throw a Clerk error when renderer is not configured', () => { + createClerkBridge({ storage }); + + const getHandler = (channel: string) => + vi.mocked(ipcMain.handle).mock.calls.find(([registered]) => registered === channel)?.[1]; + + for (const channel of Object.values(OAUTH_TRANSPORT_CHANNELS)) { + const handler = getHandler(channel); + expect(handler).toBeDefined(); + expect(() => handler?.(mainFrameEvent, 'https://accounts.example.com')).toThrow( + 'Clerk: OAuth sign-in requires the renderer option', + ); + } + expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalled(); + }); + + it('cleans up the missing-renderer OAuth transport handlers', () => { + const clerk = createClerkBridge({ storage }); + + clerk.cleanup(); + + expect(ipcMain.removeHandler).toHaveBeenCalledWith(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(OAUTH_TRANSPORT_CHANNELS.open); }); it('cleans up OAuth transport handlers when renderer origin is configured', () => { diff --git a/packages/electron/src/main/create-clerk-bridge.ts b/packages/electron/src/main/create-clerk-bridge.ts index d47881e81d1..e65a5ae860b 100644 --- a/packages/electron/src/main/create-clerk-bridge.ts +++ b/packages/electron/src/main/create-clerk-bridge.ts @@ -2,7 +2,7 @@ import { app, protocol } from 'electron'; import type { ClerkBridge, CreateClerkBridgeOptions } from '../shared/types'; import { setupTokenCacheIpcHandlers } from './ipc-handlers'; -import { setupOAuthTransportIpcHandlers } from './oauth-transport'; +import { setupMissingRendererOAuthTransportIpcHandlers, setupOAuthTransportIpcHandlers } from './oauth-transport'; import { setupPasskeysMain } from './passkey-handlers'; function assertValidRendererOriginConfig(renderer: NonNullable): void { @@ -127,6 +127,8 @@ export function createClerkBridge(options: CreateClerkBridgeOptions): ClerkBridg renderer: options.renderer, }), ); + } else { + teardowns.push(setupMissingRendererOAuthTransportIpcHandlers()); } return { diff --git a/packages/electron/src/main/oauth-transport.ts b/packages/electron/src/main/oauth-transport.ts index 763636a87de..be4831f199e 100644 --- a/packages/electron/src/main/oauth-transport.ts +++ b/packages/electron/src/main/oauth-transport.ts @@ -157,3 +157,19 @@ export function setupOAuthTransportIpcHandlers(options: OAuthTransportOptions): ipcMain.removeHandler(OAUTH_TRANSPORT_CHANNELS.open); }; } + +export function setupMissingRendererOAuthTransportIpcHandlers(): () => void { + const rejectMissingRenderer = (): never => { + throw new Error( + 'Clerk: OAuth sign-in requires the renderer option. Pass createClerkBridge({ renderer: { scheme, host } }) so the OAuth callback can reach the app.', + ); + }; + + ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl, rejectMissingRenderer); + ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.open, rejectMissingRenderer); + + return () => { + ipcMain.removeHandler(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl); + ipcMain.removeHandler(OAUTH_TRANSPORT_CHANNELS.open); + }; +} diff --git a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx index a7b451521f7..01c965a9a2d 100644 --- a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx +++ b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx @@ -99,6 +99,22 @@ describe('Electron ClerkProvider', () => { }); }); + it('reports the Electron SDK in sdkMetadata even when a caller passes its own', () => { + renderToStaticMarkup( + + App + , + ); + + expect(capturedProviderProps?.sdkMetadata).toEqual({ + name: '@clerk/electron', + version: '0.0.0-test', + }); + }); + describe('router handlers', () => { const renderWithRouter = (props: Record = {}) => { renderToStaticMarkup( diff --git a/packages/electron/src/react/experimental.ts b/packages/electron/src/react/experimental.ts new file mode 100644 index 00000000000..fc3fa4e8f09 --- /dev/null +++ b/packages/electron/src/react/experimental.ts @@ -0,0 +1 @@ +export * from '@clerk/react/experimental'; diff --git a/packages/electron/src/react/index.tsx b/packages/electron/src/react/index.tsx index 5dffcb43752..7aa1e66343e 100644 --- a/packages/electron/src/react/index.tsx +++ b/packages/electron/src/react/index.tsx @@ -34,6 +34,11 @@ export type ClerkProviderProps = Omit< passkeys?: PasskeySupport; }; +const SDK_METADATA = { + name: PACKAGE_NAME, + version: PACKAGE_VERSION, +}; + let cachedClerkUI: { promise: Promise; publishableKey: string } | null = null; function loadClerkUI(publishableKey: string, props: Partial): Promise { @@ -136,6 +141,7 @@ export function ClerkProvider({ { index: './src/index.ts', 'preload/index': './src/preload/index.ts', 'react/index': './src/react/index.tsx', + 'react/experimental': './src/react/experimental.ts', 'storage/index': './src/storage/index.ts', 'passkeys/index': './src/passkeys/index.ts', },