From 48a548499ca73b607c9007593a5124d41610e755 Mon Sep 17 00:00:00 2001
From: Jeremy Wright
Date: Tue, 9 Jun 2026 01:14:03 +0400
Subject: [PATCH 1/7] feat(electron): scaffold initial @clerk/electron package
Add the initial packages/electron package with metadata, dual ESM+CJS
tsup build, exports, and a README placeholder. Exposes the intended
entrypoints with stubbed bodies:
- @clerk/electron (Electron main process)
- @clerk/electron/preload (Electron preload scripts)
---
.changeset/electron-package-scaffold.md | 2 +
packages/electron/LICENSE | 21 +++++
packages/electron/README.md | 59 ++++++++++++++
packages/electron/package.json | 82 ++++++++++++++++++++
packages/electron/src/global.d.ts | 3 +
packages/electron/src/index.ts | 2 +
packages/electron/src/preload/index.ts | 2 +
packages/electron/tsconfig.declarations.json | 12 +++
packages/electron/tsconfig.json | 23 ++++++
packages/electron/tsup.config.ts | 38 +++++++++
pnpm-lock.yaml | 12 ++-
11 files changed, 255 insertions(+), 1 deletion(-)
create mode 100644 .changeset/electron-package-scaffold.md
create mode 100644 packages/electron/LICENSE
create mode 100644 packages/electron/README.md
create mode 100644 packages/electron/package.json
create mode 100644 packages/electron/src/global.d.ts
create mode 100644 packages/electron/src/index.ts
create mode 100644 packages/electron/src/preload/index.ts
create mode 100644 packages/electron/tsconfig.declarations.json
create mode 100644 packages/electron/tsconfig.json
create mode 100644 packages/electron/tsup.config.ts
diff --git a/.changeset/electron-package-scaffold.md b/.changeset/electron-package-scaffold.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/electron-package-scaffold.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/electron/LICENSE b/packages/electron/LICENSE
new file mode 100644
index 00000000000..daceccfbc84
--- /dev/null
+++ b/packages/electron/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Clerk, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/electron/README.md b/packages/electron/README.md
new file mode 100644
index 00000000000..65101286b3e
--- /dev/null
+++ b/packages/electron/README.md
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
@clerk/electron
+
+
+
+
+[](https://clerk.com/docs?utm_source=github&utm_medium=clerk_electron)
+[](https://x.com/intent/follow?screen_name=clerk)
+
+[Changelog](https://github.com/clerk/javascript/blob/main/packages/electron/CHANGELOG.md)
+·
+[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml)
+·
+[Request a Feature](https://feedback.clerk.com/roadmap)
+·
+[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_electron)
+
+
+
+## Getting Started
+
+[Clerk](https://clerk.com/?utm_source=github&utm_medium=clerk_electron) is the easiest way to add authentication and user management to your Electron application.
+
+> [!WARNING]
+> `@clerk/electron` is under active development and is not yet ready for production use. The API is incomplete and subject to change.
+
+This package exposes two entrypoints, targeting Electron's distinct runtime contexts:
+
+- `@clerk/electron` — for use in the Electron **main** process.
+- `@clerk/electron/preload` — for use in Electron **preload** scripts.
+
+## Support
+
+For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_electron).
+
+## Contributing
+
+We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md).
+
+## Security
+
+`@clerk/electron` follows good practices of security, but 100% security cannot be assured.
+
+`@clerk/electron` is provided **"as is"** without any **warranty**. Use at your own risk.
+
+_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._
+
+## License
+
+This project is licensed under the **MIT license**.
+
+See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/electron/LICENSE) for more information.
diff --git a/packages/electron/package.json b/packages/electron/package.json
new file mode 100644
index 00000000000..dfb89cee8b6
--- /dev/null
+++ b/packages/electron/package.json
@@ -0,0 +1,82 @@
+{
+ "name": "@clerk/electron",
+ "version": "0.0.0",
+ "description": "Clerk SDK for Electron",
+ "keywords": [
+ "clerk",
+ "electron",
+ "auth",
+ "authentication",
+ "session",
+ "jwt",
+ "desktop"
+ ],
+ "homepage": "https://clerk.com/",
+ "bugs": {
+ "url": "https://github.com/clerk/javascript/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/clerk/javascript.git",
+ "directory": "packages/electron"
+ },
+ "license": "MIT",
+ "author": "Clerk",
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/types/index.d.ts",
+ "default": "./dist/esm/index.js"
+ },
+ "require": {
+ "types": "./dist/types/index.d.ts",
+ "default": "./dist/cjs/index.js"
+ }
+ },
+ "./preload": {
+ "import": {
+ "types": "./dist/types/preload/index.d.ts",
+ "default": "./dist/esm/preload/index.js"
+ },
+ "require": {
+ "types": "./dist/types/preload/index.d.ts",
+ "default": "./dist/cjs/preload/index.js"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "main": "./dist/cjs/index.js",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/types/index.d.ts",
+ "files": [
+ "dist",
+ "preload"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "build:declarations": "tsc -p tsconfig.declarations.json",
+ "clean": "rimraf ./dist",
+ "dev": "tsup --watch",
+ "format": "node ../../scripts/format-package.mjs",
+ "format:check": "node ../../scripts/format-package.mjs --check",
+ "lint": "eslint src",
+ "lint:attw": "attw --pack . --profile node16 --ignore-rules unexpected-module-syntax",
+ "lint:publint": "publint",
+ "test": "vitest run",
+ "test:ci": "vitest run --maxWorkers=70%",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "tslib": "catalog:repo"
+ },
+ "devDependencies": {
+ "@types/node": "^22.19.17"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/electron/src/global.d.ts b/packages/electron/src/global.d.ts
new file mode 100644
index 00000000000..09ed4b48d45
--- /dev/null
+++ b/packages/electron/src/global.d.ts
@@ -0,0 +1,3 @@
+declare const PACKAGE_NAME: string;
+declare const PACKAGE_VERSION: string;
+declare const __DEV__: boolean;
diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts
new file mode 100644
index 00000000000..4266b361b4a
--- /dev/null
+++ b/packages/electron/src/index.ts
@@ -0,0 +1,2 @@
+// TODO: Implement the Clerk SDK for the Electron main process.
+export const SDK_NAME = PACKAGE_NAME;
diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts
new file mode 100644
index 00000000000..81876ea7a2a
--- /dev/null
+++ b/packages/electron/src/preload/index.ts
@@ -0,0 +1,2 @@
+// TODO: Implement the Clerk preload bridge for the Electron preload context.
+export const SDK_NAME = PACKAGE_NAME;
diff --git a/packages/electron/tsconfig.declarations.json b/packages/electron/tsconfig.declarations.json
new file mode 100644
index 00000000000..c42a5efd18e
--- /dev/null
+++ b/packages/electron/tsconfig.declarations.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "skipLibCheck": true,
+ "noEmit": false,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "declarationMap": true,
+ "sourceMap": false,
+ "declarationDir": "./dist/types"
+ }
+}
diff --git a/packages/electron/tsconfig.json b/packages/electron/tsconfig.json
new file mode 100644
index 00000000000..274fd384521
--- /dev/null
+++ b/packages/electron/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2019",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "preserve",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "declaration": true,
+ "declarationDir": "dist/types",
+ "declarationMap": true,
+ "emitDeclarationOnly": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/electron/tsup.config.ts b/packages/electron/tsup.config.ts
new file mode 100644
index 00000000000..ff369e0c90d
--- /dev/null
+++ b/packages/electron/tsup.config.ts
@@ -0,0 +1,38 @@
+import type { Options } from 'tsup';
+import { defineConfig } from 'tsup';
+
+import { runAfterLast } from '../../scripts/utils';
+import { name, version } from './package.json';
+
+export default defineConfig(overrideOptions => {
+ const isWatch = !!overrideOptions.watch;
+ const shouldPublish = !!overrideOptions.env?.publish;
+
+ const common: Options = {
+ entry: ['./src/index.ts', './src/preload/index.ts'],
+ bundle: true,
+ clean: true,
+ minify: false,
+ sourcemap: true,
+ legacyOutput: true,
+ treeshake: true,
+ define: {
+ PACKAGE_NAME: `"${name}"`,
+ PACKAGE_VERSION: `"${version}"`,
+ __DEV__: `${isWatch}`,
+ },
+ };
+
+ const esm: Options = {
+ ...common,
+ format: 'esm',
+ };
+
+ const cjs: Options = {
+ ...common,
+ format: 'cjs',
+ outDir: './dist/cjs',
+ };
+
+ return runAfterLast(['pnpm build:declarations', shouldPublish && 'pkglab pub --ping'])(esm, cjs);
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b02d0e16fe2..21c4b1dd91a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -515,6 +515,16 @@ importers:
specifier: ^5.10.0
version: 5.10.0
+ packages/electron:
+ dependencies:
+ tslib:
+ specifier: catalog:repo
+ version: 2.8.1
+ devDependencies:
+ '@types/node':
+ specifier: ^22.19.17
+ version: 22.19.17
+
packages/expo:
dependencies:
'@clerk/clerk-js':
@@ -2883,7 +2893,7 @@ packages:
'@expo/bunyan@4.0.1':
resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==}
- engines: {node: '>=0.10.0'}
+ engines: {'0': node >=0.10.0}
'@expo/cli@0.22.28':
resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==}
From 61ccd5d59fd02fec0aa4cd53a7b2c1d689311f32 Mon Sep 17 00:00:00 2001
From: Robert Soriano
Date: Tue, 9 Jun 2026 09:36:55 -0700
Subject: [PATCH 2/7] chore(electron): Add initial main and preload scripts
(#8785)
---
packages/electron/package.json | 23 +-
packages/electron/src/global.d.ts | 10 +
packages/electron/src/index.ts | 4 +-
.../src/main/__tests__/ipc-handlers.test.ts | 63 ++++
.../src/main/__tests__/setup-main.test.ts | 43 +++
packages/electron/src/main/ipc-handlers.ts | 24 ++
packages/electron/src/main/setup-main.ts | 18 +
.../src/preload/__tests__/index.test.ts | 71 ++++
packages/electron/src/preload/index.ts | 20 +-
packages/electron/src/shared/ipc.ts | 5 +
packages/electron/src/shared/types.ts | 21 ++
.../src/storage/__tests__/index.test.ts | 80 ++++
packages/electron/src/storage/index.ts | 35 ++
packages/electron/tsup.config.ts | 2 +-
pnpm-lock.yaml | 346 +++++++++++++++++-
15 files changed, 758 insertions(+), 7 deletions(-)
create mode 100644 packages/electron/src/main/__tests__/ipc-handlers.test.ts
create mode 100644 packages/electron/src/main/__tests__/setup-main.test.ts
create mode 100644 packages/electron/src/main/ipc-handlers.ts
create mode 100644 packages/electron/src/main/setup-main.ts
create mode 100644 packages/electron/src/preload/__tests__/index.test.ts
create mode 100644 packages/electron/src/shared/ipc.ts
create mode 100644 packages/electron/src/shared/types.ts
create mode 100644 packages/electron/src/storage/__tests__/index.test.ts
create mode 100644 packages/electron/src/storage/index.ts
diff --git a/packages/electron/package.json b/packages/electron/package.json
index dfb89cee8b6..30c26ce3f0a 100644
--- a/packages/electron/package.json
+++ b/packages/electron/package.json
@@ -44,6 +44,16 @@
"default": "./dist/cjs/preload/index.js"
}
},
+ "./storage": {
+ "import": {
+ "types": "./dist/types/storage/index.d.ts",
+ "default": "./dist/esm/storage/index.js"
+ },
+ "require": {
+ "types": "./dist/types/storage/index.d.ts",
+ "default": "./dist/cjs/storage/index.js"
+ }
+ },
"./package.json": "./package.json"
},
"main": "./dist/cjs/index.js",
@@ -71,7 +81,18 @@
"tslib": "catalog:repo"
},
"devDependencies": {
- "@types/node": "^22.19.17"
+ "@types/node": "^22.19.17",
+ "electron": "^39.2.6",
+ "electron-store": "^8.2.0"
+ },
+ "peerDependencies": {
+ "electron": ">=28",
+ "electron-store": "^8.2.0"
+ },
+ "peerDependenciesMeta": {
+ "electron-store": {
+ "optional": true
+ }
},
"engines": {
"node": ">=20.9.0"
diff --git a/packages/electron/src/global.d.ts b/packages/electron/src/global.d.ts
index 09ed4b48d45..6c762af29a5 100644
--- a/packages/electron/src/global.d.ts
+++ b/packages/electron/src/global.d.ts
@@ -1,3 +1,13 @@
+import type { TokenCache } from './shared/types';
+
declare const PACKAGE_NAME: string;
declare const PACKAGE_VERSION: string;
declare const __DEV__: boolean;
+
+declare global {
+ interface Window {
+ __clerk_internal_electron?: {
+ tokenCache: TokenCache;
+ };
+ }
+}
diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts
index 4266b361b4a..80aa04dd58e 100644
--- a/packages/electron/src/index.ts
+++ b/packages/electron/src/index.ts
@@ -1,2 +1,2 @@
-// TODO: Implement the Clerk SDK for the Electron main process.
-export const SDK_NAME = PACKAGE_NAME;
+export { setupMain } from './main/setup-main';
+export type { TokenStorage } from './shared/types';
diff --git a/packages/electron/src/main/__tests__/ipc-handlers.test.ts b/packages/electron/src/main/__tests__/ipc-handlers.test.ts
new file mode 100644
index 00000000000..c57c98220f4
--- /dev/null
+++ b/packages/electron/src/main/__tests__/ipc-handlers.test.ts
@@ -0,0 +1,63 @@
+import { ipcMain } from 'electron';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { TOKEN_CACHE_CHANNELS } from '../../shared/ipc';
+import type { TokenStorage } from '../../shared/types';
+import { setupTokenCacheIpcHandlers } from '../ipc-handlers';
+
+const ipcEvent = {} as Electron.IpcMainInvokeEvent;
+
+vi.mock('electron', () => ({
+ ipcMain: {
+ handle: vi.fn(),
+ removeHandler: vi.fn(),
+ },
+}));
+
+describe('setupTokenCacheIpcHandlers', () => {
+ const storage: TokenStorage = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('registers token cache IPC handlers', () => {
+ setupTokenCacheIpcHandlers(storage);
+
+ expect(ipcMain.handle).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.getToken, expect.any(Function));
+ expect(ipcMain.handle).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.saveToken, expect.any(Function));
+ expect(ipcMain.handle).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.clearToken, expect.any(Function));
+ });
+
+ it('delegates token operations to the storage adapter', async () => {
+ vi.mocked(storage.getItem).mockResolvedValue('jwt');
+
+ setupTokenCacheIpcHandlers(storage);
+
+ const getTokenHandler = vi.mocked(ipcMain.handle).mock.calls[0][1];
+ const saveTokenHandler = vi.mocked(ipcMain.handle).mock.calls[1][1];
+ const clearTokenHandler = vi.mocked(ipcMain.handle).mock.calls[2][1];
+
+ await expect(getTokenHandler(ipcEvent, 'token-key')).resolves.toBe('jwt');
+ await saveTokenHandler(ipcEvent, 'token-key', 'jwt');
+ await clearTokenHandler(ipcEvent, 'token-key');
+
+ expect(storage.getItem).toHaveBeenCalledWith('token-key');
+ expect(storage.setItem).toHaveBeenCalledWith('token-key', 'jwt');
+ expect(storage.removeItem).toHaveBeenCalledWith('token-key');
+ });
+
+ it('removes registered handlers on cleanup', () => {
+ const cleanup = setupTokenCacheIpcHandlers(storage);
+
+ cleanup();
+
+ expect(ipcMain.removeHandler).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.getToken);
+ expect(ipcMain.removeHandler).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.saveToken);
+ expect(ipcMain.removeHandler).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.clearToken);
+ });
+});
diff --git a/packages/electron/src/main/__tests__/setup-main.test.ts b/packages/electron/src/main/__tests__/setup-main.test.ts
new file mode 100644
index 00000000000..c1f90c90677
--- /dev/null
+++ b/packages/electron/src/main/__tests__/setup-main.test.ts
@@ -0,0 +1,43 @@
+import { ipcMain } from 'electron';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { TokenStorage } from '../../shared/types';
+import { setupMain } from '../setup-main';
+
+vi.mock('electron', () => ({
+ ipcMain: {
+ handle: vi.fn(),
+ removeHandler: vi.fn(),
+ },
+}));
+
+describe('setupMain', () => {
+ const missingStorage = {} as Parameters[0];
+ const storage: TokenStorage = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('requires a storage adapter', () => {
+ expect(() => setupMain(missingStorage)).toThrow('setupMain requires a storage adapter');
+ });
+
+ it('sets up token persistence IPC handlers with the provided storage', () => {
+ setupMain({ storage });
+
+ expect(ipcMain.handle).toHaveBeenCalledTimes(3);
+ });
+
+ it('returns a cleanup function for registered handlers', () => {
+ const clerk = setupMain({ storage });
+
+ clerk.cleanup();
+
+ expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/packages/electron/src/main/ipc-handlers.ts b/packages/electron/src/main/ipc-handlers.ts
new file mode 100644
index 00000000000..5114729404b
--- /dev/null
+++ b/packages/electron/src/main/ipc-handlers.ts
@@ -0,0 +1,24 @@
+import { ipcMain } from 'electron';
+
+import { TOKEN_CACHE_CHANNELS } from '../shared/ipc';
+import type { TokenStorage } from '../shared/types';
+
+export function setupTokenCacheIpcHandlers(storage: TokenStorage): () => void {
+ ipcMain.handle(TOKEN_CACHE_CHANNELS.getToken, (_event, key: string) => {
+ return storage.getItem(key);
+ });
+
+ ipcMain.handle(TOKEN_CACHE_CHANNELS.saveToken, (_event, key: string, value: string) => {
+ return storage.setItem(key, value);
+ });
+
+ ipcMain.handle(TOKEN_CACHE_CHANNELS.clearToken, (_event, key: string) => {
+ return storage.removeItem(key);
+ });
+
+ return () => {
+ ipcMain.removeHandler(TOKEN_CACHE_CHANNELS.getToken);
+ ipcMain.removeHandler(TOKEN_CACHE_CHANNELS.saveToken);
+ ipcMain.removeHandler(TOKEN_CACHE_CHANNELS.clearToken);
+ };
+}
diff --git a/packages/electron/src/main/setup-main.ts b/packages/electron/src/main/setup-main.ts
new file mode 100644
index 00000000000..64c1853f2d8
--- /dev/null
+++ b/packages/electron/src/main/setup-main.ts
@@ -0,0 +1,18 @@
+import type { SetupMainOptions, SetupMainReturn } from '../shared/types';
+import { setupTokenCacheIpcHandlers } from './ipc-handlers';
+
+export function setupMain(options: SetupMainOptions): SetupMainReturn {
+ if (!options.storage) {
+ throw new Error(
+ 'Clerk: setupMain requires a storage adapter. Pass setupMain({ storage: storage() }) from @clerk/electron/storage, or provide a custom storage adapter.',
+ );
+ }
+
+ const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage);
+
+ return {
+ cleanup() {
+ cleanupTokenPersistence();
+ },
+ };
+}
diff --git a/packages/electron/src/preload/__tests__/index.test.ts b/packages/electron/src/preload/__tests__/index.test.ts
new file mode 100644
index 00000000000..28c329ed7b4
--- /dev/null
+++ b/packages/electron/src/preload/__tests__/index.test.ts
@@ -0,0 +1,71 @@
+import { contextBridge, ipcRenderer } from 'electron';
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { TOKEN_CACHE_CHANNELS } from '../../shared/ipc';
+import { setupPreload } from '../index';
+
+vi.mock('electron', () => ({
+ contextBridge: {
+ exposeInMainWorld: vi.fn(),
+ },
+ ipcRenderer: {
+ invoke: vi.fn(),
+ },
+}));
+
+describe('setupPreload', () => {
+ const originalContextIsolated = process.contextIsolated;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true });
+ vi.stubGlobal('window', {});
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ afterAll(() => {
+ Object.defineProperty(process, 'contextIsolated', { configurable: true, value: originalContextIsolated });
+ });
+
+ it('exposes the Clerk Electron bridge through contextBridge when context isolation is enabled', () => {
+ setupPreload();
+
+ expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith('__clerk_internal_electron', {
+ tokenCache: {
+ getToken: expect.any(Function),
+ saveToken: expect.any(Function),
+ clearToken: expect.any(Function),
+ },
+ });
+ });
+
+ it('exposes the Clerk Electron bridge on window when context isolation is disabled', () => {
+ Object.defineProperty(process, 'contextIsolated', { configurable: true, value: false });
+
+ setupPreload();
+
+ expect(window.__clerk_internal_electron?.tokenCache).toEqual({
+ getToken: expect.any(Function),
+ saveToken: expect.any(Function),
+ clearToken: expect.any(Function),
+ });
+ });
+
+ it('forwards token cache calls over IPC', async () => {
+ setupPreload();
+
+ const bridge = vi.mocked(contextBridge.exposeInMainWorld).mock
+ .calls[0][1] as typeof window.__clerk_internal_electron;
+
+ await bridge?.tokenCache.getToken('token-key');
+ await bridge?.tokenCache.saveToken('token-key', 'jwt');
+ await bridge?.tokenCache.clearToken('token-key');
+
+ expect(ipcRenderer.invoke).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.getToken, 'token-key');
+ expect(ipcRenderer.invoke).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.saveToken, 'token-key', 'jwt');
+ expect(ipcRenderer.invoke).toHaveBeenCalledWith(TOKEN_CACHE_CHANNELS.clearToken, 'token-key');
+ });
+});
diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts
index 81876ea7a2a..8b9903b1c4d 100644
--- a/packages/electron/src/preload/index.ts
+++ b/packages/electron/src/preload/index.ts
@@ -1,2 +1,18 @@
-// TODO: Implement the Clerk preload bridge for the Electron preload context.
-export const SDK_NAME = PACKAGE_NAME;
+import { contextBridge, ipcRenderer } from 'electron';
+
+import { TOKEN_CACHE_CHANNELS } from '../shared/ipc';
+import type { TokenCache } from '../shared/types';
+
+export function setupPreload(): void {
+ const tokenCache: TokenCache = {
+ getToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.getToken, key),
+ saveToken: (key, value) => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.saveToken, key, value),
+ clearToken: key => ipcRenderer.invoke(TOKEN_CACHE_CHANNELS.clearToken, key),
+ };
+
+ if (process.contextIsolated) {
+ contextBridge.exposeInMainWorld('__clerk_internal_electron', { tokenCache });
+ } else {
+ window.__clerk_internal_electron = { tokenCache };
+ }
+}
diff --git a/packages/electron/src/shared/ipc.ts b/packages/electron/src/shared/ipc.ts
new file mode 100644
index 00000000000..e50db4bb9c3
--- /dev/null
+++ b/packages/electron/src/shared/ipc.ts
@@ -0,0 +1,5 @@
+export const TOKEN_CACHE_CHANNELS = {
+ getToken: 'clerk:token-cache:get',
+ saveToken: 'clerk:token-cache:save',
+ clearToken: 'clerk:token-cache:clear',
+} as const;
diff --git a/packages/electron/src/shared/types.ts b/packages/electron/src/shared/types.ts
new file mode 100644
index 00000000000..9c51778cc28
--- /dev/null
+++ b/packages/electron/src/shared/types.ts
@@ -0,0 +1,21 @@
+type Awaitable = T | Promise;
+
+export type TokenStorage = {
+ getItem: (key: string) => Awaitable;
+ setItem: (key: string, value: string) => Awaitable;
+ removeItem: (key: string) => Awaitable;
+};
+
+export type SetupMainOptions = {
+ storage: TokenStorage;
+};
+
+export type SetupMainReturn = {
+ cleanup: () => void;
+};
+
+export type TokenCache = {
+ getToken: (key: string) => Promise;
+ saveToken: (key: string, value: string) => Promise;
+ clearToken: (key: string) => Promise;
+};
diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts
new file mode 100644
index 00000000000..406d98651b3
--- /dev/null
+++ b/packages/electron/src/storage/__tests__/index.test.ts
@@ -0,0 +1,80 @@
+import { safeStorage } from 'electron';
+import Store from 'electron-store';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { storage } from '../index';
+
+const storeGet = vi.fn();
+const storeSet = vi.fn();
+const storeDelete = vi.fn();
+
+vi.mock('electron', () => ({
+ safeStorage: {
+ decryptString: vi.fn(),
+ encryptString: vi.fn(),
+ },
+}));
+
+vi.mock('electron-store', () => ({
+ default: vi.fn(() => ({
+ get: storeGet,
+ set: storeSet,
+ delete: storeDelete,
+ })),
+}));
+
+describe('storage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('creates an electron-store instance with the default store name', () => {
+ storage();
+
+ expect(Store).toHaveBeenCalledWith({ name: 'clerk-tokens' });
+ });
+
+ it('supports a custom store name', () => {
+ storage({ name: 'custom-clerk-tokens' });
+
+ expect(Store).toHaveBeenCalledWith({ name: 'custom-clerk-tokens' });
+ });
+
+ it('returns null when a token is missing', () => {
+ storeGet.mockReturnValue(undefined);
+
+ expect(storage().getItem('token-key')).toBeNull();
+ });
+
+ it('decrypts stored tokens', () => {
+ storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64'));
+ vi.mocked(safeStorage.decryptString).mockReturnValue('jwt');
+
+ expect(storage().getItem('token-key')).toBe('jwt');
+ expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-token'));
+ });
+
+ it('returns null when token decryption fails', () => {
+ storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64'));
+ vi.mocked(safeStorage.decryptString).mockImplementation(() => {
+ throw new Error('decrypt failed');
+ });
+
+ expect(storage().getItem('token-key')).toBeNull();
+ });
+
+ it('encrypts tokens before storing them', async () => {
+ vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-token'));
+
+ await storage().setItem('token-key', 'jwt');
+
+ expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt');
+ expect(storeSet).toHaveBeenCalledWith('token-key', Buffer.from('encrypted-token').toString('base64'));
+ });
+
+ it('removes stored tokens', async () => {
+ await storage().removeItem('token-key');
+
+ expect(storeDelete).toHaveBeenCalledWith('token-key');
+ });
+});
diff --git a/packages/electron/src/storage/index.ts b/packages/electron/src/storage/index.ts
new file mode 100644
index 00000000000..ce130078db0
--- /dev/null
+++ b/packages/electron/src/storage/index.ts
@@ -0,0 +1,35 @@
+import { safeStorage } from 'electron';
+import Store from 'electron-store';
+
+import type { TokenStorage } from '../shared/types';
+
+type StorageOptions = {
+ name?: string;
+};
+
+export function storage(options: StorageOptions = {}): TokenStorage {
+ const store = new Store>({ name: options.name ?? 'clerk-tokens' });
+
+ return {
+ getItem(key) {
+ const encrypted = store.get(key);
+
+ if (!encrypted) {
+ return null;
+ }
+
+ try {
+ return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
+ } catch {
+ return null;
+ }
+ },
+ setItem(key, value) {
+ const encrypted = safeStorage.encryptString(value);
+ store.set(key, encrypted.toString('base64'));
+ },
+ removeItem(key) {
+ store.delete(key);
+ },
+ };
+}
diff --git a/packages/electron/tsup.config.ts b/packages/electron/tsup.config.ts
index ff369e0c90d..5a598814cb3 100644
--- a/packages/electron/tsup.config.ts
+++ b/packages/electron/tsup.config.ts
@@ -9,7 +9,7 @@ export default defineConfig(overrideOptions => {
const shouldPublish = !!overrideOptions.env?.publish;
const common: Options = {
- entry: ['./src/index.ts', './src/preload/index.ts'],
+ entry: ['./src/index.ts', './src/preload/index.ts', './src/storage/index.ts'],
bundle: true,
clean: true,
minify: false,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 21c4b1dd91a..7d1a9aa86d0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -524,6 +524,12 @@ importers:
'@types/node':
specifier: ^22.19.17
version: 22.19.17
+ electron:
+ specifier: ^39.2.6
+ version: 39.8.10
+ electron-store:
+ specifier: ^8.2.0
+ version: 8.2.0
packages/expo:
dependencies:
@@ -2313,6 +2319,10 @@ packages:
resolution: {integrity: sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==}
engines: {node: '>=18'}
+ '@electron/get@2.0.3':
+ resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==}
+ engines: {node: '>=12'}
+
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -2893,7 +2903,7 @@ packages:
'@expo/bunyan@4.0.1':
resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==}
- engines: {'0': node >=0.10.0}
+ engines: {node: '>=0.10.0'}
'@expo/cli@0.22.28':
resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==}
@@ -5448,6 +5458,10 @@ packages:
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
+ '@szmarczak/http-timer@4.0.6':
+ resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
+ engines: {node: '>=10'}
+
'@tailwindcss/node@4.3.0':
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
@@ -5762,6 +5776,9 @@ packages:
'@types/bonjour@3.5.13':
resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==}
+ '@types/cacheable-request@6.0.3':
+ resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -5834,6 +5851,9 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+ '@types/http-cache-semantics@4.2.0':
+ resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
+
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
@@ -5864,6 +5884,9 @@ packages:
'@types/jsonfile@6.1.4':
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
+ '@types/keyv@3.1.4':
+ resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
+
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -5926,6 +5949,9 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+ '@types/responselike@1.0.3':
+ resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
+
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
@@ -6908,6 +6934,10 @@ packages:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
+ atomically@1.7.0:
+ resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
+ engines: {node: '>=10.12.0'}
+
autoprefixer@10.5.0:
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
engines: {node: ^10 || ^12 || >=14}
@@ -7131,6 +7161,10 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+ boolean@3.2.0:
+ resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
+
borsh@0.7.0:
resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==}
@@ -7256,6 +7290,14 @@ packages:
resolution: {integrity: sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==}
engines: {node: ^16.14.0 || >=18.0.0}
+ cacheable-lookup@5.0.4:
+ resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==}
+ engines: {node: '>=10.6.0'}
+
+ cacheable-request@7.0.4:
+ resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==}
+ engines: {node: '>=8'}
+
cachedir@2.4.0:
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
engines: {node: '>=6'}
@@ -7507,6 +7549,9 @@ packages:
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
engines: {node: '>=6'}
+ clone-response@1.0.3:
+ resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
+
clone@1.0.4:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
@@ -7665,6 +7710,10 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+ conf@10.2.0:
+ resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==}
+ engines: {node: '>=12'}
+
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -8020,6 +8069,10 @@ packages:
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
+ debounce-fn@4.0.0:
+ resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
+ engines: {node: '>=10'}
+
debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
@@ -8088,6 +8141,10 @@ packages:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
+ decompress-response@6.0.0:
+ resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
+ engines: {node: '>=10'}
+
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@@ -8134,6 +8191,10 @@ packages:
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
+ defer-to-connect@2.0.1:
+ resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
+ engines: {node: '>=10'}
+
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
@@ -8286,6 +8347,10 @@ packages:
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
engines: {node: '>=8'}
+ dot-prop@6.0.1:
+ resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
+ engines: {node: '>=10'}
+
dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
@@ -8360,9 +8425,17 @@ packages:
engines: {node: '>=0.10.0'}
hasBin: true
+ electron-store@8.2.0:
+ resolution: {integrity: sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==}
+
electron-to-chromium@1.5.331:
resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==}
+ electron@39.8.10:
+ resolution: {integrity: sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==}
+ engines: {node: '>= 12.20.55'}
+ hasBin: true
+
emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
@@ -8513,6 +8586,9 @@ packages:
es-toolkit@1.47.0:
resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==}
+ es6-error@4.1.1:
+ resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
+
es6-promise@4.2.8:
resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==}
@@ -9529,6 +9605,10 @@ packages:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ global-agent@3.0.0:
+ resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
+ engines: {node: '>=10.0'}
+
global-directory@4.0.1:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
@@ -9577,6 +9657,10 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
+ got@11.8.6:
+ resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==}
+ engines: {node: '>=10.19.0'}
+
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -9848,6 +9932,10 @@ packages:
resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==}
engines: {node: '>=0.10'}
+ http2-wrapper@1.0.3:
+ resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==}
+ engines: {node: '>=10.19.0'}
+
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -10549,6 +10637,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+ json-schema-typed@7.0.3:
+ resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==}
+
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
@@ -10982,6 +11073,10 @@ packages:
lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
+ lowercase-keys@2.0.0:
+ resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
+ engines: {node: '>=8'}
+
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -11081,6 +11176,10 @@ packages:
marky@1.3.0:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
+ matcher@3.0.0:
+ resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
+ engines: {node: '>=10'}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -11479,6 +11578,10 @@ packages:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
+ mimic-fn@3.1.0:
+ resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==}
+ engines: {node: '>=8'}
+
mimic-fn@4.0.0:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
@@ -11487,6 +11590,14 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
+ mimic-response@1.0.1:
+ resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
+ engines: {node: '>=4'}
+
+ mimic-response@3.1.0:
+ resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
+ engines: {node: '>=10'}
+
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
@@ -11800,6 +11911,10 @@ packages:
resolution: {integrity: sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw==}
engines: {node: '>=4'}
+ normalize-url@6.1.0:
+ resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
+ engines: {node: '>=10'}
+
npm-package-arg@11.0.3:
resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==}
engines: {node: ^16.14.0 || >=18.0.0}
@@ -12056,6 +12171,10 @@ packages:
peerDependencies:
oxc-parser: '>=0.98.0'
+ p-cancelable@2.1.1:
+ resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
+ engines: {node: '>=8'}
+
p-event@5.0.1:
resolution: {integrity: sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -12356,6 +12475,10 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
+ pkg-up@3.1.0:
+ resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
+ engines: {node: '>=8'}
+
pkglab-darwin-arm64@0.17.1:
resolution: {integrity: sha512-7cy7ck7iQW6xYDBkV3NHW1wz4bub9sikFkrMj11AT/1PXVYFDhI7SaaZ6jxQgL3Mcvj34rQlUXKyVWrAzhrUDw==}
engines: {node: '>=18'}
@@ -12886,6 +13009,10 @@ packages:
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
+ quick-lru@5.1.1:
+ resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
+ engines: {node: '>=10'}
+
quick-lru@6.1.2:
resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==}
engines: {node: '>=12'}
@@ -13183,6 +13310,9 @@ packages:
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
+ resolve-alpn@1.2.1:
+ resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
+
resolve-from@3.0.0:
resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==}
engines: {node: '>=4'}
@@ -13221,6 +13351,9 @@ packages:
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
hasBin: true
+ responselike@2.0.1:
+ resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==}
+
restore-cursor@2.0.0:
resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
engines: {node: '>=4'}
@@ -13286,6 +13419,10 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
+ roarr@2.15.4:
+ resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
+ engines: {node: '>=8.0'}
+
rolldown-plugin-dts@0.16.12:
resolution: {integrity: sha512-9dGjm5oqtKcbZNhpzyBgb8KrYiU616A7IqcFWG7Msp1RKAXQ/hapjivRg+g5IYWSiFhnk3OKYV5T4Ft1t8Cczg==}
engines: {node: '>=20.18.0'}
@@ -13438,6 +13575,9 @@ packages:
resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==}
engines: {node: '>=10'}
+ semver-compare@1.0.0:
+ resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
+
semver-regex@4.0.5:
resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==}
engines: {node: '>=12'}
@@ -13463,6 +13603,10 @@ packages:
resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==}
engines: {node: '>=0.10.0'}
+ serialize-error@7.0.1:
+ resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
+ engines: {node: '>=10'}
+
serialize-javascript@7.0.5:
resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==}
engines: {node: '>=20.0.0'}
@@ -13748,6 +13892,9 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+ sprintf-js@1.1.3:
+ resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
+
srvx@0.10.1:
resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==}
engines: {node: '>=20.16.0'}
@@ -14018,6 +14165,10 @@ packages:
suf-log@2.5.3:
resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==}
+ sumchecker@3.0.1:
+ resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==}
+ engines: {node: '>= 8.0'}
+
superagent@8.1.2:
resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==}
engines: {node: '>=6.4.0 <13 || >=14'}
@@ -14418,6 +14569,10 @@ packages:
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
engines: {node: '>=4'}
+ type-fest@0.13.1:
+ resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
+ engines: {node: '>=10'}
+
type-fest@0.16.0:
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
engines: {node: '>=10'}
@@ -17150,6 +17305,20 @@ snapshots:
dependencies:
'@edge-runtime/primitives': 6.0.0
+ '@electron/get@2.0.3':
+ dependencies:
+ debug: 4.4.3(supports-color@8.1.1)
+ env-paths: 2.2.1
+ fs-extra: 8.1.0
+ got: 11.8.6
+ progress: 2.0.3
+ semver: 7.7.4
+ sumchecker: 3.0.1
+ optionalDependencies:
+ global-agent: 3.0.0
+ transitivePeerDependencies:
+ - supports-color
+
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -21104,6 +21273,10 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@szmarczak/http-timer@4.0.6':
+ dependencies:
+ defer-to-connect: 2.0.1
+
'@tailwindcss/node@4.3.0':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -21487,6 +21660,13 @@ snapshots:
dependencies:
'@types/node': 22.19.17
+ '@types/cacheable-request@6.0.3':
+ dependencies:
+ '@types/http-cache-semantics': 4.2.0
+ '@types/keyv': 3.1.4
+ '@types/node': 22.19.17
+ '@types/responselike': 1.0.3
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -21580,6 +21760,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/http-cache-semantics@4.2.0': {}
+
'@types/http-errors@2.0.5': {}
'@types/http-proxy@1.17.17':
@@ -21611,6 +21793,10 @@ snapshots:
dependencies:
'@types/node': 22.19.17
+ '@types/keyv@3.1.4':
+ dependencies:
+ '@types/node': 22.19.17
+
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -21669,6 +21855,10 @@ snapshots:
'@types/resolve@1.20.2': {}
+ '@types/responselike@1.0.3':
+ dependencies:
+ '@types/node': 22.19.17
+
'@types/retry@0.12.2': {}
'@types/semver@7.7.1': {}
@@ -22942,6 +23132,8 @@ snapshots:
atomic-sleep@1.0.0: {}
+ atomically@1.7.0: {}
+
autoprefixer@10.5.0(postcss@8.5.13):
dependencies:
browserslist: 4.28.2
@@ -23217,6 +23409,9 @@ snapshots:
boolbase@1.0.0: {}
+ boolean@3.2.0:
+ optional: true
+
borsh@0.7.0:
dependencies:
bn.js: 5.2.3
@@ -23384,6 +23579,18 @@ snapshots:
tar: 7.5.11
unique-filename: 3.0.0
+ cacheable-lookup@5.0.4: {}
+
+ cacheable-request@7.0.4:
+ dependencies:
+ clone-response: 1.0.3
+ get-stream: 5.2.0
+ http-cache-semantics: 4.2.0
+ keyv: 4.5.4
+ lowercase-keys: 2.0.0
+ normalize-url: 6.1.0
+ responselike: 2.0.1
+
cachedir@2.4.0: {}
call-bind-apply-helpers@1.0.2:
@@ -23661,6 +23868,10 @@ snapshots:
kind-of: 6.0.3
shallow-clone: 3.0.1
+ clone-response@1.0.3:
+ dependencies:
+ mimic-response: 1.0.1
+
clone@1.0.4: {}
clsx@1.2.1: {}
@@ -23782,6 +23993,19 @@ snapshots:
concat-map@0.0.1: {}
+ conf@10.2.0:
+ dependencies:
+ ajv: 8.20.0
+ ajv-formats: 2.1.1(ajv@8.20.0)
+ atomically: 1.7.0
+ debounce-fn: 4.0.0
+ dot-prop: 6.0.1
+ env-paths: 2.2.1
+ json-schema-typed: 7.0.3
+ onetime: 5.1.2
+ pkg-up: 3.1.0
+ semver: 7.7.4
+
confbox@0.1.8: {}
confbox@0.2.4: {}
@@ -24198,6 +24422,10 @@ snapshots:
de-indent@1.0.2: {}
+ debounce-fn@4.0.0:
+ dependencies:
+ mimic-fn: 3.1.0
+
debounce@1.2.1: {}
debug@2.6.9:
@@ -24241,6 +24469,10 @@ snapshots:
decode-uri-component@0.2.2: {}
+ decompress-response@6.0.0:
+ dependencies:
+ mimic-response: 3.1.0
+
dedent@1.7.2(babel-plugin-macros@3.1.0):
optionalDependencies:
babel-plugin-macros: 3.1.0
@@ -24294,6 +24526,8 @@ snapshots:
dependencies:
clone: 1.0.4
+ defer-to-connect@2.0.1: {}
+
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
@@ -24433,6 +24667,10 @@ snapshots:
dependencies:
is-obj: 2.0.0
+ dot-prop@6.0.1:
+ dependencies:
+ is-obj: 2.0.0
+
dotenv-expand@11.0.7:
dependencies:
dotenv: 16.6.1
@@ -24492,8 +24730,21 @@ snapshots:
dependencies:
jake: 10.9.4
+ electron-store@8.2.0:
+ dependencies:
+ conf: 10.2.0
+ type-fest: 2.19.0
+
electron-to-chromium@1.5.331: {}
+ electron@39.8.10:
+ dependencies:
+ '@electron/get': 2.0.3
+ '@types/node': 22.19.17
+ extract-zip: 2.0.1(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - supports-color
+
emoji-regex-xs@1.0.0: {}
emoji-regex@10.6.0: {}
@@ -24708,6 +24959,9 @@ snapshots:
es-toolkit@1.47.0: {}
+ es6-error@4.1.1:
+ optional: true
+
es6-promise@4.2.8: {}
es6-promisify@5.0.0:
@@ -26094,6 +26348,16 @@ snapshots:
once: 1.4.0
path-is-absolute: 1.0.1
+ global-agent@3.0.0:
+ dependencies:
+ boolean: 3.2.0
+ es6-error: 4.1.1
+ matcher: 3.0.0
+ roarr: 2.15.4
+ semver: 7.7.4
+ serialize-error: 7.0.1
+ optional: true
+
global-directory@4.0.1:
dependencies:
ini: 4.1.1
@@ -26149,6 +26413,20 @@ snapshots:
gopd@1.2.0: {}
+ got@11.8.6:
+ dependencies:
+ '@sindresorhus/is': 4.6.0
+ '@szmarczak/http-timer': 4.0.6
+ '@types/cacheable-request': 6.0.3
+ '@types/responselike': 1.0.3
+ cacheable-lookup: 5.0.4
+ cacheable-request: 7.0.4
+ decompress-response: 6.0.0
+ http2-wrapper: 1.0.3
+ lowercase-keys: 2.0.0
+ p-cancelable: 2.1.1
+ responselike: 2.0.1
+
graceful-fs@4.2.11: {}
graphql@16.13.2: {}
@@ -26541,6 +26819,11 @@ snapshots:
jsprim: 2.0.2
sshpk: 1.18.0
+ http2-wrapper@1.0.3:
+ dependencies:
+ quick-lru: 5.1.1
+ resolve-alpn: 1.2.1
+
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -27233,6 +27516,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
+ json-schema-typed@7.0.3: {}
+
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
@@ -27648,6 +27933,8 @@ snapshots:
dependencies:
tslib: 2.8.1
+ lowercase-keys@2.0.0: {}
+
lru-cache@10.4.3: {}
lru-cache@11.3.5: {}
@@ -27749,6 +28036,11 @@ snapshots:
marky@1.3.0: {}
+ matcher@3.0.0:
+ dependencies:
+ escape-string-regexp: 4.0.0
+ optional: true
+
math-intrinsics@1.1.0: {}
md5-file@3.2.3:
@@ -28636,10 +28928,16 @@ snapshots:
mimic-fn@2.1.0: {}
+ mimic-fn@3.1.0: {}
+
mimic-fn@4.0.0: {}
mimic-function@5.0.1: {}
+ mimic-response@1.0.1: {}
+
+ mimic-response@3.1.0: {}
+
min-indent@1.0.1: {}
minimalistic-assert@1.0.1: {}
@@ -29097,6 +29395,8 @@ snapshots:
query-string: 5.1.1
sort-keys: 2.0.0
+ normalize-url@6.1.0: {}
+
npm-package-arg@11.0.3:
dependencies:
hosted-git-info: 7.0.2
@@ -29606,6 +29906,8 @@ snapshots:
magic-regexp: 0.10.0
oxc-parser: 0.128.0
+ p-cancelable@2.1.1: {}
+
p-event@5.0.1:
dependencies:
p-timeout: 5.1.0
@@ -29879,6 +30181,10 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
+ pkg-up@3.1.0:
+ dependencies:
+ find-up: 3.0.0
+
pkglab-darwin-arm64@0.17.1:
optional: true
@@ -30303,6 +30609,8 @@ snapshots:
quick-format-unescaped@4.0.4: {}
+ quick-lru@5.1.1: {}
+
quick-lru@6.1.2: {}
radix3@1.1.2: {}
@@ -30746,6 +31054,8 @@ snapshots:
reselect@5.2.0: {}
+ resolve-alpn@1.2.1: {}
+
resolve-from@3.0.0:
optional: true
@@ -30779,6 +31089,10 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
+ responselike@2.0.1:
+ dependencies:
+ lowercase-keys: 2.0.0
+
restore-cursor@2.0.0:
dependencies:
onetime: 2.0.1
@@ -30849,6 +31163,16 @@ snapshots:
glob: 11.0.3
package-json-from-dist: 1.0.1
+ roarr@2.15.4:
+ dependencies:
+ boolean: 3.2.0
+ detect-node: 2.1.0
+ globalthis: 1.0.4
+ json-stringify-safe: 5.0.1
+ semver-compare: 1.0.0
+ sprintf-js: 1.1.3
+ optional: true
+
rolldown-plugin-dts@0.16.12(rolldown@1.0.0-beta.47(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.8.3)(vue-tsc@3.1.4(typescript@5.8.3)):
dependencies:
'@babel/generator': 7.29.1
@@ -31057,6 +31381,9 @@ snapshots:
'@types/node-forge': 1.3.14
node-forge: 1.4.0
+ semver-compare@1.0.0:
+ optional: true
+
semver-regex@4.0.5: {}
semver@7.7.4: {}
@@ -31115,6 +31442,11 @@ snapshots:
serialize-error@2.1.0: {}
+ serialize-error@7.0.1:
+ dependencies:
+ type-fest: 0.13.1
+ optional: true
+
serialize-javascript@7.0.5: {}
seroval-plugins@1.5.0(seroval@1.5.2):
@@ -31552,6 +31884,9 @@ snapshots:
sprintf-js@1.0.3: {}
+ sprintf-js@1.1.3:
+ optional: true
+
srvx@0.10.1: {}
srvx@0.11.15: {}
@@ -31840,6 +32175,12 @@ snapshots:
dependencies:
s.color: 0.0.15
+ sumchecker@3.0.1:
+ dependencies:
+ debug: 4.4.3(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - supports-color
+
superagent@8.1.2:
dependencies:
component-emitter: 1.3.1
@@ -32239,6 +32580,9 @@ snapshots:
type-detect@4.1.0: {}
+ type-fest@0.13.1:
+ optional: true
+
type-fest@0.16.0: {}
type-fest@0.21.3: {}
From 1c224106253eaa8622a0d4678b2e1b844e6ebf96 Mon Sep 17 00:00:00 2001
From: Jeremy Wright
Date: Wed, 10 Jun 2026 04:39:39 +0400
Subject: [PATCH 3/7] feat(electron): harden token storage encryption (#8791)
---
.../src/storage/__tests__/index.test.ts | 269 ++++++++++++++++--
packages/electron/src/storage/index.ts | 214 +++++++++++++-
2 files changed, 450 insertions(+), 33 deletions(-)
diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts
index 406d98651b3..0afedbd498f 100644
--- a/packages/electron/src/storage/__tests__/index.test.ts
+++ b/packages/electron/src/storage/__tests__/index.test.ts
@@ -8,11 +8,11 @@ const storeGet = vi.fn();
const storeSet = vi.fn();
const storeDelete = vi.fn();
+// `safeStorage` starts empty; each test installs only the methods for the backend it exercises.
+// This mirrors reality: Electron < 42 has no async methods at all, so `resolveCipher` only takes the
+// async path when both the methods exist and `isAsyncEncryptionAvailable()` confirms it.
vi.mock('electron', () => ({
- safeStorage: {
- decryptString: vi.fn(),
- encryptString: vi.fn(),
- },
+ safeStorage: {},
}));
vi.mock('electron-store', () => ({
@@ -23,10 +23,32 @@ vi.mock('electron-store', () => ({
})),
}));
-describe('storage', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
+const ss = safeStorage as unknown as Record;
+
+/** Installs the synchronous `safeStorage` API. */
+function installSync({ available = true }: { available?: boolean } = {}) {
+ ss.isEncryptionAvailable = vi.fn(() => available);
+ ss.encryptString = vi.fn((value: string) => Buffer.from(`enc(${value})`));
+ ss.decryptString = vi.fn();
+}
+
+/** Adds the Electron 42+ asynchronous `safeStorage` API. */
+function installAsync({ available = true }: { available?: boolean } = {}) {
+ ss.isAsyncEncryptionAvailable = vi.fn(() => Promise.resolve(available));
+ ss.encryptStringAsync = vi.fn((value: string) => Promise.resolve(Buffer.from(`enc(${value})`)));
+ ss.decryptStringAsync = vi.fn();
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset the mocked `safeStorage` shape so backend detection starts from a clean slate.
+ for (const key of Object.keys(ss)) {
+ delete ss[key];
+ }
+});
+
+describe('storage options', () => {
+ beforeEach(() => installSync());
it('creates an electron-store instance with the default store name', () => {
storage();
@@ -40,38 +62,239 @@ describe('storage', () => {
expect(Store).toHaveBeenCalledWith({ name: 'custom-clerk-tokens' });
});
- it('returns null when a token is missing', () => {
+ it('forwards a custom path as electron-store `cwd`', () => {
+ storage({ path: '/tmp/clerk' });
+
+ expect(Store).toHaveBeenCalledWith({ name: 'clerk-tokens', cwd: '/tmp/clerk' });
+ });
+
+ it('omits `cwd` when no path is provided', () => {
+ storage();
+
+ expect(Store).toHaveBeenCalledWith(expect.not.objectContaining({ cwd: expect.anything() }));
+ });
+});
+
+describe('getItem', () => {
+ it('returns null when a token is missing', async () => {
+ installSync();
storeGet.mockReturnValue(undefined);
- expect(storage().getItem('token-key')).toBeNull();
+ await expect(storage().getItem('token-key')).resolves.toBeNull();
+ });
+
+ it('returns an unencrypted (raw:) value as-is without decrypting', async () => {
+ installSync();
+ storeGet.mockReturnValue('raw:jwt');
+
+ await expect(storage().getItem('token-key')).resolves.toBe('jwt');
+ expect(safeStorage.decryptString).not.toHaveBeenCalled();
+ });
+
+ it('deletes and returns null for an unrecognized value format', async () => {
+ installSync();
+ storeGet.mockReturnValue('garbage-without-prefix');
+
+ await expect(storage().getItem('token-key')).resolves.toBeNull();
+ expect(storeDelete).toHaveBeenCalledWith('token-key');
+ });
+
+ it('preserves the entry and returns null when no OS encryption is available', async () => {
+ installSync({ available: false });
+ storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`);
+
+ await expect(storage().getItem('token-key')).resolves.toBeNull();
+ expect(storeDelete).not.toHaveBeenCalled();
+ });
+
+ describe('sync backend', () => {
+ it('decrypts stored tokens', async () => {
+ installSync();
+ storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`);
+ vi.mocked(safeStorage.decryptString).mockReturnValue('jwt');
+
+ await expect(storage().getItem('token-key')).resolves.toBe('jwt');
+ expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('cipher'));
+ });
+
+ it('returns null without deleting the entry when decryption fails', async () => {
+ installSync();
+ storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`);
+ vi.mocked(safeStorage.decryptString).mockImplementation(() => {
+ throw new Error('decrypt failed');
+ });
+
+ await expect(storage().getItem('token-key')).resolves.toBeNull();
+ expect(storeDelete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('async backend', () => {
+ it('decrypts stored tokens', async () => {
+ installSync();
+ installAsync();
+ storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`);
+ vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: false });
+
+ await expect(storage().getItem('token-key')).resolves.toBe('jwt');
+ expect(safeStorage.decryptStringAsync).toHaveBeenCalledWith(Buffer.from('cipher'));
+ });
+
+ it('re-encrypts and re-saves when the OS key has rotated (shouldReEncrypt)', async () => {
+ installSync();
+ installAsync();
+ storeGet.mockReturnValue(`enc:${Buffer.from('old-cipher').toString('base64')}`);
+ vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true });
+
+ await expect(storage().getItem('token-key')).resolves.toBe('jwt');
+ expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt');
+ expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`);
+ });
+
+ it('returns null without deleting the entry when decryption rejects', async () => {
+ installSync();
+ installAsync();
+ storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`);
+ vi.mocked(safeStorage.decryptStringAsync).mockRejectedValue(new Error('decrypt failed'));
+
+ await expect(storage().getItem('token-key')).resolves.toBeNull();
+ expect(storeDelete).not.toHaveBeenCalled();
+ });
+
+ it('falls back to the sync API (never calling the async crypto) when async encryption is unavailable', async () => {
+ installSync();
+ installAsync({ available: false });
+ storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`);
+ vi.mocked(safeStorage.decryptString).mockReturnValue('jwt');
+
+ await expect(storage().getItem('token-key')).resolves.toBe('jwt');
+ expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('cipher'));
+ // Critical: calling the async crypto while unavailable crashes the process on Electron 42.x.
+ expect(safeStorage.decryptStringAsync).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('setItem', () => {
+ it('encrypts tokens before storing them (sync backend)', async () => {
+ installSync();
+
+ await storage().setItem('token-key', 'jwt');
+
+ expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt');
+ expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`);
+ });
+
+ it('encrypts tokens before storing them (async backend)', async () => {
+ installSync();
+ installAsync();
+
+ await storage().setItem('token-key', 'jwt');
+
+ expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt');
+ expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`);
+ });
+
+ it('falls back to the sync API (never calling the async crypto) when async encryption is unavailable', async () => {
+ installSync();
+ installAsync({ available: false });
+
+ await storage().setItem('token-key', 'jwt');
+
+ expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt');
+ expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled();
+ expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`);
+ });
+
+ it('does not persist when no encryption is available and no fallback is configured', async () => {
+ installSync({ available: false });
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ await storage().setItem('token-key', 'jwt');
+
+ expect(storeSet).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalledOnce();
+
+ warn.mockRestore();
});
- it('decrypts stored tokens', () => {
- storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64'));
- vi.mocked(safeStorage.decryptString).mockReturnValue('jwt');
+ it('persists unencrypted when no encryption is available and unencryptedFallback is enabled', async () => {
+ installSync({ available: false });
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ await storage({ unencryptedFallback: true }).setItem('token-key', 'jwt');
- expect(storage().getItem('token-key')).toBe('jwt');
- expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-token'));
+ expect(storeSet).toHaveBeenCalledWith('token-key', 'raw:jwt');
+ expect(warn).toHaveBeenCalledOnce();
+
+ warn.mockRestore();
});
- it('returns null when token decryption fails', () => {
- storeGet.mockReturnValue(Buffer.from('encrypted-token').toString('base64'));
- vi.mocked(safeStorage.decryptString).mockImplementation(() => {
- throw new Error('decrypt failed');
+ it('only warns once across repeated saves', async () => {
+ installSync({ available: false });
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const adapter = storage();
+ await adapter.setItem('a', '1');
+ await adapter.setItem('b', '2');
+
+ expect(warn).toHaveBeenCalledOnce();
+
+ warn.mockRestore();
+ });
+
+ it('does not downgrade to plaintext when encryption is available but encrypt() fails', async () => {
+ ss.isEncryptionAvailable = vi.fn(() => true);
+ ss.encryptString = vi.fn(() => {
+ throw new Error('encrypt failed');
});
+ ss.decryptString = vi.fn();
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ // Even with the fallback enabled, a *failed encrypt* (vs. unavailable encryption) must not be
+ // persisted in the clear.
+ await storage({ unencryptedFallback: true }).setItem('token-key', 'jwt');
+
+ expect(storeSet).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalledOnce();
+
+ warn.mockRestore();
+ });
- expect(storage().getItem('token-key')).toBeNull();
+ it('resolves the cipher lazily and retries when it was initially unavailable (e.g. before app ready)', async () => {
+ // Unavailable on the first probe (pre-`ready`), available afterwards.
+ ss.isEncryptionAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
+ ss.encryptString = vi.fn((value: string) => Buffer.from(`enc(${value})`));
+ ss.decryptString = vi.fn();
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const adapter = storage();
+
+ await adapter.setItem('token-key', 'jwt');
+ expect(storeSet).not.toHaveBeenCalled(); // not persisted while unavailable
+
+ await adapter.setItem('token-key', 'jwt');
+ expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`);
+
+ warn.mockRestore();
});
- it('encrypts tokens before storing them', async () => {
- vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-token'));
+ it('ignores a partial async surface and uses the sync API', async () => {
+ // `encryptStringAsync` exists but the rest of the async trio does not — must not take the async
+ // path (and must not call the async crypto).
+ ss.encryptStringAsync = vi.fn();
+ ss.isEncryptionAvailable = vi.fn(() => true);
+ ss.encryptString = vi.fn((value: string) => Buffer.from(`enc(${value})`));
+ ss.decryptString = vi.fn();
await storage().setItem('token-key', 'jwt');
expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt');
- expect(storeSet).toHaveBeenCalledWith('token-key', Buffer.from('encrypted-token').toString('base64'));
+ expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled();
});
+});
+describe('removeItem', () => {
it('removes stored tokens', async () => {
await storage().removeItem('token-key');
diff --git a/packages/electron/src/storage/index.ts b/packages/electron/src/storage/index.ts
index ce130078db0..8dd10f5f95b 100644
--- a/packages/electron/src/storage/index.ts
+++ b/packages/electron/src/storage/index.ts
@@ -4,30 +4,224 @@ import Store from 'electron-store';
import type { TokenStorage } from '../shared/types';
type StorageOptions = {
+ /**
+ * The name of the file (without extension) used to persist tokens.
+ *
+ * @default 'clerk-tokens'
+ */
name?: string;
+ /**
+ * The directory in which the token file is stored. Maps to `electron-store`'s `cwd` option.
+ * When omitted, the OS default user config directory is used.
+ */
+ path?: string;
+ /**
+ * When OS-level encryption is unavailable (e.g. a Linux machine without a keyring), tokens are
+ * not persisted by default, which signs the user out on the next app launch. Set this to `true`
+ * to instead persist tokens **unencrypted** in that scenario, keeping the user signed in across
+ * restarts at the cost of storing tokens in plaintext on disk.
+ *
+ * @default false
+ */
+ unencryptedFallback?: boolean;
};
+/**
+ * Marks a value that was encrypted via {@link safeStorage}; the remainder is base64 ciphertext.
+ * values will be stored as: `enc:`
+ */
+const ENCRYPTED_PREFIX = 'enc:';
+/**
+ * Marks a value persisted unencrypted via the `unencryptedFallback` option.
+ * values will be stored as: `raw:`
+ */
+const RAW_PREFIX = 'raw:';
+
+type Cipher = {
+ encrypt: (value: string) => Promise;
+ /**
+ * Decrypts a base64 payload. `shouldReEncrypt` is `true` (async backend only) when the OS key
+ * has rotated and the payload should be re-encrypted with the new key.
+ */
+ decrypt: (payload: string) => Promise<{ value: string; shouldReEncrypt: boolean }>;
+};
+
+type AsyncSafeStorage = {
+ encryptStringAsync: (plainText: string) => Promise;
+ decryptStringAsync: (encrypted: Buffer) => Promise<{ result: string; shouldReEncrypt: boolean }>;
+ isAsyncEncryptionAvailable: () => Promise;
+};
+
+const syncCipher: Cipher = {
+ encrypt: value => Promise.resolve(safeStorage.encryptString(value).toString('base64')),
+ decrypt: payload =>
+ Promise.resolve({ value: safeStorage.decryptString(Buffer.from(payload, 'base64')), shouldReEncrypt: false }),
+};
+
+function hasAsyncSafeStorage(storage: typeof safeStorage): storage is typeof safeStorage & AsyncSafeStorage {
+ const candidate = storage as Partial;
+
+ return (
+ typeof candidate.encryptStringAsync === 'function' &&
+ typeof candidate.decryptStringAsync === 'function' &&
+ typeof candidate.isAsyncEncryptionAvailable === 'function'
+ );
+}
+
+function createAsyncCipher(storage: AsyncSafeStorage): Cipher {
+ return {
+ encrypt: async value => (await storage.encryptStringAsync(value)).toString('base64'),
+ decrypt: async payload => {
+ const { result, shouldReEncrypt } = await storage.decryptStringAsync(Buffer.from(payload, 'base64'));
+ return { value: result, shouldReEncrypt };
+ },
+ };
+}
+
+/**
+ * Resolves the crypto backend to use, or `null` when no OS encryption is currently available.
+ */
+async function resolveCipher(): Promise {
+ // Prefer the async API, but only when the full optional function surface exists.
+ if (hasAsyncSafeStorage(safeStorage)) {
+ try {
+ if (await safeStorage.isAsyncEncryptionAvailable()) {
+ return createAsyncCipher(safeStorage);
+ }
+ } catch {
+ /* fall through to the synchronous API */
+ }
+ }
+
+ // The synchronous API blocks the calling thread on the OS prompt during the first encrypt/decrypt,
+ if (typeof safeStorage.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()) {
+ return syncCipher;
+ }
+
+ return null;
+}
+
+/**
+ * Creates a secure {@link TokenStorage} adapter for the Electron main process.
+ *
+ * Tokens are persisted with `electron-store` and encrypted at rest using Electron's
+ * {@link safeStorage} API, which is backed by the OS keystore (Keychain on macOS, DPAPI on
+ * Windows, libsecret/kwallet on Linux). It uses Electron 42's async `safeStorage` API only when it
+ * reports itself available (which generally requires a code-signed app) and otherwise falls back to
+ * the synchronous API. Pass the result to `setupMain({ storage: storage() })`.
+ *
+ * Behavior is secure by default: when OS encryption is unavailable the adapter does not persist
+ * tokens (the user will be signed out on restart) unless {@link StorageOptions.unencryptedFallback}
+ * is enabled. Undecryptable entries return `null`.
+ *
+ * @example
+ * ```ts
+ * import { setupMain } from '@clerk/electron';
+ * import { storage } from '@clerk/electron/storage';
+ *
+ * setupMain({ storage: storage({ name: 'my-app-tokens' }) });
+ * ```
+ */
export function storage(options: StorageOptions = {}): TokenStorage {
- const store = new Store>({ name: options.name ?? 'clerk-tokens' });
+ const store = new Store>({
+ name: options.name ?? 'clerk-tokens',
+ ...(options.path ? { cwd: options.path } : {}),
+ });
+
+ let cachedCipher: Cipher | null = null;
+ let resolving: Promise | null = null;
+ const getCipher = (): Promise => {
+ if (cachedCipher) {
+ return Promise.resolve(cachedCipher);
+ }
+ resolving ??= resolveCipher().then(resolved => {
+ resolving = null;
+ if (resolved) {
+ cachedCipher = resolved;
+ }
+ return resolved;
+ });
+ return resolving;
+ };
+
+ let warned = false;
+ const warnOnce = (message: string) => {
+ if (warned) {
+ return;
+ }
+ warned = true;
+ console.warn(message);
+ };
return {
- getItem(key) {
- const encrypted = store.get(key);
+ async getItem(key) {
+ const stored = store.get(key);
- if (!encrypted) {
+ if (!stored) {
return null;
}
+ if (stored.startsWith(RAW_PREFIX)) {
+ return stored.slice(RAW_PREFIX.length);
+ }
+
+ if (stored.startsWith(ENCRYPTED_PREFIX)) {
+ const cipher = await getCipher();
+
+ // No usable OS encryption, preserve entry.
+ if (!cipher) {
+ return null;
+ }
+
+ const payload = stored.slice(ENCRYPTED_PREFIX.length);
+
+ try {
+ const { value, shouldReEncrypt } = await cipher.decrypt(payload);
+
+ if (shouldReEncrypt) {
+ // OS key has rotated, persist with new value
+ try {
+ store.set(key, ENCRYPTED_PREFIX + (await cipher.encrypt(value)));
+ } catch {
+ // keep the existing payload; it still decrypts for now
+ }
+ }
+
+ return value;
+ } catch {
+ // Decryption failed, preserve the entry, new write on next sign-in.
+ return null;
+ }
+ }
+
+ // Unknown or unrecognized format, drop the entry so we don't repeatedly fail on it.
+ store.delete(key);
+ return null;
+ },
+ async setItem(key, value) {
+ const cipher = await getCipher();
+
+ if (!cipher) {
+ if (options.unencryptedFallback) {
+ warnOnce(
+ 'Clerk: OS encryption is unavailable; falling back to unencrypted storage. Session tokens are being stored unencrypted on local disk.',
+ );
+ store.set(key, RAW_PREFIX + value);
+ } else {
+ warnOnce(
+ 'Clerk: OS encryption is unavailable and unencryptedFallback is not enabled, so tokens are not being persisted. The user will be signed out on the next launch. Pass `storage({ unencryptedFallback: true })` to persist unencrypted (less secure).',
+ );
+ }
+ return;
+ }
+
try {
- return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
+ store.set(key, ENCRYPTED_PREFIX + (await cipher.encrypt(value)));
} catch {
- return null;
+ // Encryption is available but encryption failed
+ warnOnce('Clerk: failed to encrypt the session token; it was not persisted.');
}
},
- setItem(key, value) {
- const encrypted = safeStorage.encryptString(value);
- store.set(key, encrypted.toString('base64'));
- },
removeItem(key) {
store.delete(key);
},
From 74eb5c32e42b70c2c070e2b58cb175b6736965dd Mon Sep 17 00:00:00 2001
From: Jeremy Wright
Date: Wed, 10 Jun 2026 06:25:29 +0400
Subject: [PATCH 4/7] fix(electron): resolve storage type errors (#8792)
---
.../src/storage/__tests__/index.test.ts | 25 ++++++++++++-------
packages/electron/tsconfig.declarations.json | 3 ++-
2 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts
index 0afedbd498f..aa930e632b3 100644
--- a/packages/electron/src/storage/__tests__/index.test.ts
+++ b/packages/electron/src/storage/__tests__/index.test.ts
@@ -8,6 +8,12 @@ const storeGet = vi.fn();
const storeSet = vi.fn();
const storeDelete = vi.fn();
+type AsyncSafeStorage = {
+ encryptStringAsync: (value: string) => Promise;
+ decryptStringAsync: (encrypted: Buffer) => Promise<{ result: string; shouldReEncrypt: boolean }>;
+ isAsyncEncryptionAvailable: () => Promise;
+};
+
// `safeStorage` starts empty; each test installs only the methods for the backend it exercises.
// This mirrors reality: Electron < 42 has no async methods at all, so `resolveCipher` only takes the
// async path when both the methods exist and `isAsyncEncryptionAvailable()` confirms it.
@@ -24,6 +30,7 @@ vi.mock('electron-store', () => ({
}));
const ss = safeStorage as unknown as Record;
+const asyncSafeStorage = safeStorage as typeof safeStorage & AsyncSafeStorage;
/** Installs the synchronous `safeStorage` API. */
function installSync({ available = true }: { available?: boolean } = {}) {
@@ -134,20 +141,20 @@ describe('getItem', () => {
installSync();
installAsync();
storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`);
- vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: false });
+ vi.mocked(asyncSafeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: false });
await expect(storage().getItem('token-key')).resolves.toBe('jwt');
- expect(safeStorage.decryptStringAsync).toHaveBeenCalledWith(Buffer.from('cipher'));
+ expect(asyncSafeStorage.decryptStringAsync).toHaveBeenCalledWith(Buffer.from('cipher'));
});
it('re-encrypts and re-saves when the OS key has rotated (shouldReEncrypt)', async () => {
installSync();
installAsync();
storeGet.mockReturnValue(`enc:${Buffer.from('old-cipher').toString('base64')}`);
- vi.mocked(safeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true });
+ vi.mocked(asyncSafeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true });
await expect(storage().getItem('token-key')).resolves.toBe('jwt');
- expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt');
+ expect(asyncSafeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt');
expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`);
});
@@ -155,7 +162,7 @@ describe('getItem', () => {
installSync();
installAsync();
storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`);
- vi.mocked(safeStorage.decryptStringAsync).mockRejectedValue(new Error('decrypt failed'));
+ vi.mocked(asyncSafeStorage.decryptStringAsync).mockRejectedValue(new Error('decrypt failed'));
await expect(storage().getItem('token-key')).resolves.toBeNull();
expect(storeDelete).not.toHaveBeenCalled();
@@ -170,7 +177,7 @@ describe('getItem', () => {
await expect(storage().getItem('token-key')).resolves.toBe('jwt');
expect(safeStorage.decryptString).toHaveBeenCalledWith(Buffer.from('cipher'));
// Critical: calling the async crypto while unavailable crashes the process on Electron 42.x.
- expect(safeStorage.decryptStringAsync).not.toHaveBeenCalled();
+ expect(asyncSafeStorage.decryptStringAsync).not.toHaveBeenCalled();
});
});
});
@@ -191,7 +198,7 @@ describe('setItem', () => {
await storage().setItem('token-key', 'jwt');
- expect(safeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt');
+ expect(asyncSafeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt');
expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`);
});
@@ -202,7 +209,7 @@ describe('setItem', () => {
await storage().setItem('token-key', 'jwt');
expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt');
- expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled();
+ expect(asyncSafeStorage.encryptStringAsync).not.toHaveBeenCalled();
expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`);
});
@@ -290,7 +297,7 @@ describe('setItem', () => {
await storage().setItem('token-key', 'jwt');
expect(safeStorage.encryptString).toHaveBeenCalledWith('jwt');
- expect(safeStorage.encryptStringAsync).not.toHaveBeenCalled();
+ expect(asyncSafeStorage.encryptStringAsync).not.toHaveBeenCalled();
});
});
diff --git a/packages/electron/tsconfig.declarations.json b/packages/electron/tsconfig.declarations.json
index c42a5efd18e..860b72f67d1 100644
--- a/packages/electron/tsconfig.declarations.json
+++ b/packages/electron/tsconfig.declarations.json
@@ -8,5 +8,6 @@
"declarationMap": true,
"sourceMap": false,
"declarationDir": "./dist/types"
- }
+ },
+ "exclude": ["**/__tests__/**/*"]
}
From 1b938fafce0b24030814aed21750e2ecf60bf920 Mon Sep 17 00:00:00 2001
From: Robert Soriano
Date: Wed, 10 Jun 2026 11:52:10 -0700
Subject: [PATCH 5/7] feat(electron): Add initial React integration export
(#8806)
---
packages/electron/README.md | 21 +++-
packages/electron/package.json | 20 +++-
.../react/__tests__/ClerkProvider.test.tsx | 111 ++++++++++++++++++
.../src/react/create-clerk-instance.ts | 40 +++++++
packages/electron/src/react/index.tsx | 35 ++++++
packages/electron/tsconfig.json | 3 +-
packages/electron/tsup.config.ts | 2 +-
pnpm-lock.yaml | 21 +++-
8 files changed, 246 insertions(+), 7 deletions(-)
create mode 100644 packages/electron/src/react/__tests__/ClerkProvider.test.tsx
create mode 100644 packages/electron/src/react/create-clerk-instance.ts
create mode 100644 packages/electron/src/react/index.tsx
diff --git a/packages/electron/README.md b/packages/electron/README.md
index 65101286b3e..f1306339367 100644
--- a/packages/electron/README.md
+++ b/packages/electron/README.md
@@ -31,10 +31,29 @@
> [!WARNING]
> `@clerk/electron` is under active development and is not yet ready for production use. The API is incomplete and subject to change.
-This package exposes two entrypoints, targeting Electron's distinct runtime contexts:
+This package exposes entrypoints for Electron's distinct runtime contexts:
- `@clerk/electron` — for use in the Electron **main** process.
- `@clerk/electron/preload` — for use in Electron **preload** scripts.
+- `@clerk/electron/react` — for use in the Electron **renderer** process.
+- `@clerk/electron/storage` — default token storage backed by `electron-store`.
+
+```ts
+// main.ts
+import { setupMain } from '@clerk/electron';
+import { storage } from '@clerk/electron/storage';
+
+setupMain({
+ storage: storage(),
+});
+```
+
+```tsx
+// renderer.tsx
+import { ClerkProvider } from '@clerk/electron/react';
+
+{/* ... */} ;
+```
## Support
diff --git a/packages/electron/package.json b/packages/electron/package.json
index 30c26ce3f0a..5347bcfed38 100644
--- a/packages/electron/package.json
+++ b/packages/electron/package.json
@@ -54,6 +54,16 @@
"default": "./dist/cjs/storage/index.js"
}
},
+ "./react": {
+ "import": {
+ "types": "./dist/types/react/index.d.ts",
+ "default": "./dist/esm/react/index.js"
+ },
+ "require": {
+ "types": "./dist/types/react/index.d.ts",
+ "default": "./dist/cjs/react/index.js"
+ }
+ },
"./package.json": "./package.json"
},
"main": "./dist/cjs/index.js",
@@ -78,6 +88,9 @@
"test:watch": "vitest"
},
"dependencies": {
+ "@clerk/clerk-js": "workspace:^",
+ "@clerk/react": "workspace:^",
+ "@clerk/ui": "workspace:^",
"tslib": "catalog:repo"
},
"devDependencies": {
@@ -87,11 +100,16 @@
},
"peerDependencies": {
"electron": ">=28",
- "electron-store": "^8.2.0"
+ "electron-store": "^8.2.0",
+ "react": "catalog:peer-react",
+ "react-dom": "catalog:peer-react"
},
"peerDependenciesMeta": {
"electron-store": {
"optional": true
+ },
+ "react-dom": {
+ "optional": true
}
},
"engines": {
diff --git a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx
new file mode 100644
index 00000000000..1aa7cdb90ce
--- /dev/null
+++ b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx
@@ -0,0 +1,111 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ClerkProvider } from '../index';
+
+let capturedProviderProps: Record | null = null;
+let beforeRequest:
+ | ((request: { credentials?: RequestCredentials; headers?: Headers; url?: URL }) => Promise)
+ | null = null;
+let afterResponse: ((request: unknown, response: Response) => Promise) | null = null;
+
+const clerkConstructor = vi.hoisted(() => vi.fn());
+
+vi.mock('@clerk/clerk-js', () => ({
+ Clerk: class MockClerk {
+ constructor(publishableKey: string) {
+ clerkConstructor(publishableKey);
+ }
+
+ __internal_onBeforeRequest(cb: typeof beforeRequest) {
+ beforeRequest = cb;
+ }
+
+ __internal_onAfterResponse(cb: typeof afterResponse) {
+ afterResponse = cb;
+ }
+ },
+}));
+
+vi.mock('@clerk/react/internal', () => ({
+ InternalClerkProvider: (props: Record) => {
+ capturedProviderProps = props;
+ return {props.children as React.ReactNode}
;
+ },
+}));
+
+vi.mock('@clerk/ui', () => ({
+ ui: { ClerkUI: 'mock-ui' },
+}));
+
+describe('Electron ClerkProvider', () => {
+ const tokenCache = {
+ clearToken: vi.fn(),
+ getToken: vi.fn(),
+ saveToken: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedProviderProps = null;
+ beforeRequest = null;
+ afterResponse = null;
+ vi.stubGlobal('window', {
+ __clerk_internal_electron: {
+ tokenCache,
+ },
+ });
+ });
+
+ it('renders React ClerkProvider with Electron defaults', () => {
+ renderToStaticMarkup(
+
+ App
+ ,
+ );
+
+ expect(clerkConstructor).toHaveBeenCalledWith('pk_test_provider');
+ expect(capturedProviderProps).toMatchObject({
+ publishableKey: 'pk_test_provider',
+ signInUrl: '/sign-in',
+ standardBrowser: false,
+ ui: { ClerkUI: 'mock-ui' },
+ });
+ expect(capturedProviderProps?.Clerk).toBeDefined();
+ expect(capturedProviderProps?.__internal_nativeOAuthHandler).toBeUndefined();
+ });
+
+ it('adds native request params and Authorization from the Electron token cache', async () => {
+ tokenCache.getToken.mockResolvedValue('client-jwt');
+ renderToStaticMarkup(App );
+
+ const request = {
+ headers: new Headers(),
+ url: new URL('https://api.clerk.test/v1/client'),
+ };
+ await beforeRequest?.(request);
+
+ expect(request.credentials).toBe('omit');
+ expect(request.url.searchParams.get('_is_native')).toBe('1');
+ expect(request.headers.get('Authorization')).toBe('Bearer client-jwt');
+ expect(tokenCache.getToken).toHaveBeenCalledWith('__clerk_client_jwt');
+ });
+
+ it('stores Authorization response headers in the Electron token cache', async () => {
+ renderToStaticMarkup(App );
+
+ await afterResponse?.(
+ {},
+ new Response(null, {
+ headers: {
+ Authorization: 'Bearer updated-client-jwt',
+ },
+ }),
+ );
+
+ expect(tokenCache.saveToken).toHaveBeenCalledWith('__clerk_client_jwt', 'updated-client-jwt');
+ });
+});
diff --git a/packages/electron/src/react/create-clerk-instance.ts b/packages/electron/src/react/create-clerk-instance.ts
new file mode 100644
index 00000000000..a56a9f6d850
--- /dev/null
+++ b/packages/electron/src/react/create-clerk-instance.ts
@@ -0,0 +1,40 @@
+import { Clerk } from '@clerk/clerk-js';
+
+const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt';
+
+type ClerkInstance = InstanceType;
+
+let cached: { instance: ClerkInstance; publishableKey: string } | null = null;
+
+export function createClerkInstance(publishableKey: string): ClerkInstance {
+ if (cached?.publishableKey === publishableKey) {
+ return cached.instance;
+ }
+
+ const clerk = new Clerk(publishableKey);
+
+ clerk.__internal_onBeforeRequest(async request => {
+ request.credentials = 'omit';
+ request.url?.searchParams.append('_is_native', '1');
+
+ const token = await window.__clerk_internal_electron?.tokenCache.getToken(CLERK_CLIENT_JWT_KEY);
+ if (token) {
+ const headers = new Headers(request.headers);
+ headers.set('Authorization', `Bearer ${token}`);
+ request.headers = headers;
+ }
+ });
+
+ clerk.__internal_onAfterResponse(async (_request, response) => {
+ const authorization = (response as Response).headers.get('Authorization');
+ if (!authorization) {
+ return;
+ }
+
+ const token = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : authorization;
+ await window.__clerk_internal_electron?.tokenCache.saveToken(CLERK_CLIENT_JWT_KEY, token);
+ });
+
+ cached = { instance: clerk, publishableKey };
+ return clerk;
+}
diff --git a/packages/electron/src/react/index.tsx b/packages/electron/src/react/index.tsx
new file mode 100644
index 00000000000..ddfa6de5607
--- /dev/null
+++ b/packages/electron/src/react/index.tsx
@@ -0,0 +1,35 @@
+import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react';
+import { InternalClerkProvider as ReactClerkProvider } from '@clerk/react/internal';
+import { ui } from '@clerk/ui';
+import type { ReactNode } from 'react';
+
+import { createClerkInstance } from './create-clerk-instance';
+
+export type ClerkProviderProps = Omit<
+ ReactClerkProviderProps,
+ 'Clerk' | 'children' | 'publishableKey' | 'standardBrowser' | 'ui'
+> & {
+ children: ReactNode;
+ /**
+ * Your Clerk publishable key, available in the Clerk Dashboard.
+ */
+ publishableKey: string;
+};
+
+export function ClerkProvider({ children, publishableKey, ...props }: ClerkProviderProps): JSX.Element {
+ const clerk = createClerkInstance(publishableKey);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export * from '@clerk/react';
diff --git a/packages/electron/tsconfig.json b/packages/electron/tsconfig.json
index 274fd384521..3969b0a2146 100644
--- a/packages/electron/tsconfig.json
+++ b/packages/electron/tsconfig.json
@@ -19,5 +19,6 @@
"declarationMap": true,
"emitDeclarationOnly": true
},
- "include": ["src"]
+ "include": ["src"],
+ "exclude": ["src/**/__tests__/**"]
}
diff --git a/packages/electron/tsup.config.ts b/packages/electron/tsup.config.ts
index 5a598814cb3..a8beef878b5 100644
--- a/packages/electron/tsup.config.ts
+++ b/packages/electron/tsup.config.ts
@@ -9,7 +9,7 @@ export default defineConfig(overrideOptions => {
const shouldPublish = !!overrideOptions.env?.publish;
const common: Options = {
- entry: ['./src/index.ts', './src/preload/index.ts', './src/storage/index.ts'],
+ entry: ['./src/index.ts', './src/preload/index.ts', './src/react/index.tsx', './src/storage/index.ts'],
bundle: true,
clean: true,
minify: false,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7d1a9aa86d0..74f44f28c86 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -392,9 +392,6 @@ importers:
'@clerk/react':
specifier: workspace:^
version: link:../react
- '@clerk/shared':
- specifier: workspace:^
- version: link:../shared
'@clerk/ui':
specifier: workspace:^
version: link:../ui
@@ -517,6 +514,24 @@ importers:
packages/electron:
dependencies:
+ '@clerk/clerk-js':
+ specifier: workspace:^
+ version: link:../clerk-js
+ '@clerk/react':
+ specifier: workspace:^
+ version: link:../react
+ '@clerk/shared':
+ specifier: workspace:^
+ version: link:../shared
+ '@clerk/ui':
+ specifier: workspace:^
+ version: link:../ui
+ react:
+ specifier: catalog:peer-react
+ version: 18.3.1
+ react-dom:
+ specifier: catalog:peer-react
+ version: 18.3.1(react@18.3.1)
tslib:
specifier: catalog:repo
version: 2.8.1
From 1c0d40c28a8ca8228613c65835e091f0b145f034 Mon Sep 17 00:00:00 2001
From: Robert Soriano
Date: Wed, 10 Jun 2026 13:51:04 -0700
Subject: [PATCH 6/7] chore: Add custom renderer origin support (#8816)
---
packages/electron/README.md | 26 ++++++++++
packages/electron/src/index.ts | 1 -
.../src/main/__tests__/setup-main.test.ts | 52 ++++++++++++++++++-
packages/electron/src/main/setup-main.ts | 33 ++++++++++++
packages/electron/src/shared/types.ts | 15 ++++++
5 files changed, 125 insertions(+), 2 deletions(-)
diff --git a/packages/electron/README.md b/packages/electron/README.md
index f1306339367..08bbb31c2bd 100644
--- a/packages/electron/README.md
+++ b/packages/electron/README.md
@@ -40,14 +40,40 @@ This package exposes entrypoints for Electron's distinct runtime contexts:
```ts
// main.ts
+import { app, BrowserWindow, net, protocol } from 'electron';
import { setupMain } from '@clerk/electron';
import { storage } from '@clerk/electron/storage';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
setupMain({
storage: storage(),
+ renderer: {
+ scheme: 'my-app',
+ host: 'renderer',
+ },
+});
+
+app.whenReady().then(() => {
+ protocol.handle('my-app', request => {
+ const url = new URL(request.url);
+ const file = url.pathname === '/' ? 'index.html' : url.pathname;
+
+ return net.fetch(pathToFileURL(join(__dirname, '../renderer', file)).toString());
+ });
+
+ const win = new BrowserWindow({
+ webPreferences: {
+ preload: join(__dirname, '../preload/index.js'),
+ },
+ });
+
+ win.loadURL('my-app://renderer/');
});
```
+In `my-app://renderer/sign-in`, `my-app` is the scheme, `renderer` is the host, `my-app://renderer` is the origin, and `/sign-in` is the path. If your renderer uses path-based routing, serve every route from the same origin and fall back to your renderer entrypoint as needed.
+
```tsx
// renderer.tsx
import { ClerkProvider } from '@clerk/electron/react';
diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts
index 80aa04dd58e..ea95302fb59 100644
--- a/packages/electron/src/index.ts
+++ b/packages/electron/src/index.ts
@@ -1,2 +1 @@
export { setupMain } from './main/setup-main';
-export type { TokenStorage } from './shared/types';
diff --git a/packages/electron/src/main/__tests__/setup-main.test.ts b/packages/electron/src/main/__tests__/setup-main.test.ts
index c1f90c90677..32701055806 100644
--- a/packages/electron/src/main/__tests__/setup-main.test.ts
+++ b/packages/electron/src/main/__tests__/setup-main.test.ts
@@ -1,4 +1,4 @@
-import { ipcMain } from 'electron';
+import { ipcMain, protocol } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TokenStorage } from '../../shared/types';
@@ -9,6 +9,9 @@ vi.mock('electron', () => ({
handle: vi.fn(),
removeHandler: vi.fn(),
},
+ protocol: {
+ registerSchemesAsPrivileged: vi.fn(),
+ },
}));
describe('setupMain', () => {
@@ -33,6 +36,53 @@ describe('setupMain', () => {
expect(ipcMain.handle).toHaveBeenCalledTimes(3);
});
+ it('registers the configured renderer scheme as privileged before app ready', () => {
+ setupMain({
+ storage,
+ renderer: {
+ host: 'renderer',
+ scheme: 'my-app',
+ },
+ });
+
+ expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
+ {
+ scheme: 'my-app',
+ privileges: {
+ corsEnabled: true,
+ secure: true,
+ standard: true,
+ stream: true,
+ supportFetchAPI: true,
+ },
+ },
+ ]);
+ });
+
+ it('requires renderer.scheme to be a scheme name, not a URL', () => {
+ expect(() =>
+ setupMain({
+ storage,
+ renderer: {
+ host: 'renderer',
+ scheme: 'my-app://',
+ },
+ }),
+ ).toThrow('renderer.scheme must be a scheme name');
+ });
+
+ it('requires renderer.host to be a host name, not an origin', () => {
+ expect(() =>
+ setupMain({
+ storage,
+ renderer: {
+ host: 'my-app://renderer',
+ scheme: 'my-app',
+ },
+ }),
+ ).toThrow('renderer.host must be a host name');
+ });
+
it('returns a cleanup function for registered handlers', () => {
const clerk = setupMain({ storage });
diff --git a/packages/electron/src/main/setup-main.ts b/packages/electron/src/main/setup-main.ts
index 64c1853f2d8..aef67fda011 100644
--- a/packages/electron/src/main/setup-main.ts
+++ b/packages/electron/src/main/setup-main.ts
@@ -1,6 +1,22 @@
+import { protocol } from 'electron';
+
import type { SetupMainOptions, SetupMainReturn } from '../shared/types';
import { setupTokenCacheIpcHandlers } from './ipc-handlers';
+function assertValidRendererOriginConfig(renderer: NonNullable): void {
+ if (renderer.scheme.includes(':') || renderer.scheme.includes('/')) {
+ throw new Error(
+ 'Clerk: renderer.scheme must be a scheme name like "my-app", not a URL or protocol like "my-app://".',
+ );
+ }
+
+ if (renderer.host.includes(':') || renderer.host.includes('/')) {
+ throw new Error(
+ 'Clerk: renderer.host must be a host name like "renderer", not a URL or origin like "my-app://renderer".',
+ );
+ }
+}
+
export function setupMain(options: SetupMainOptions): SetupMainReturn {
if (!options.storage) {
throw new Error(
@@ -10,6 +26,23 @@ export function setupMain(options: SetupMainOptions): SetupMainReturn {
const cleanupTokenPersistence = setupTokenCacheIpcHandlers(options.storage);
+ if (options.renderer) {
+ assertValidRendererOriginConfig(options.renderer);
+
+ protocol.registerSchemesAsPrivileged([
+ {
+ scheme: options.renderer.scheme,
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ corsEnabled: true,
+ stream: true,
+ },
+ },
+ ]);
+ }
+
return {
cleanup() {
cleanupTokenPersistence();
diff --git a/packages/electron/src/shared/types.ts b/packages/electron/src/shared/types.ts
index 9c51778cc28..14aea2c2a70 100644
--- a/packages/electron/src/shared/types.ts
+++ b/packages/electron/src/shared/types.ts
@@ -8,12 +8,27 @@ export type TokenStorage = {
export type SetupMainOptions = {
storage: TokenStorage;
+ /**
+ * Registers the custom scheme used to serve the Electron renderer from a stable origin.
+ */
+ renderer?: RendererSchemeOptions;
};
export type SetupMainReturn = {
cleanup: () => void;
};
+export type RendererSchemeOptions = {
+ /**
+ * Custom scheme used for the renderer origin.
+ */
+ scheme: string;
+ /**
+ * Custom host used for the renderer origin.
+ */
+ host: string;
+};
+
export type TokenCache = {
getToken: (key: string) => Promise;
saveToken: (key: string, value: string) => Promise;
From abef47caf6536b15b6b6b25479272d1040b23319 Mon Sep 17 00:00:00 2001
From: wobsoriano
Date: Wed, 10 Jun 2026 14:00:57 -0700
Subject: [PATCH 7/7] chore: dedupe
---
pnpm-lock.yaml | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 74f44f28c86..4f42f04b13a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -392,6 +392,9 @@ importers:
'@clerk/react':
specifier: workspace:^
version: link:../react
+ '@clerk/shared':
+ specifier: workspace:^
+ version: link:../shared
'@clerk/ui':
specifier: workspace:^
version: link:../ui
@@ -520,17 +523,14 @@ importers:
'@clerk/react':
specifier: workspace:^
version: link:../react
- '@clerk/shared':
- specifier: workspace:^
- version: link:../shared
'@clerk/ui':
specifier: workspace:^
version: link:../ui
react:
- specifier: catalog:peer-react
+ specifier: 18.3.1
version: 18.3.1
react-dom:
- specifier: catalog:peer-react
+ specifier: 18.3.1
version: 18.3.1(react@18.3.1)
tslib:
specifier: catalog:repo