diff --git a/apps/desktop/src/app/DesktopClerk.schemePrivileges.test.ts b/apps/desktop/src/app/DesktopClerk.schemePrivileges.test.ts new file mode 100644 index 000000000..e40a8b024 --- /dev/null +++ b/apps/desktop/src/app/DesktopClerk.schemePrivileges.test.ts @@ -0,0 +1,130 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +// Fork: runs the real @clerk/electron bridge against a stand-in for Electron's +// scheme registry, in the order main.ts starts them. The bridge registers its own +// scheme list, and the stand-in keeps what Electron 44 was seen to keep: the +// standard and stream schemes of every call, and the secure, fetch and CORS schemes +// of the last call only. +const { electron, registry } = vi.hoisted(() => { + const registry = { + standard: new Set(), + secure: [] as string[], + fetch: [] as string[], + cors: [] as string[], + stream: new Set(), + }; + type CustomScheme = { + readonly scheme: string; + readonly privileges?: { + readonly standard?: boolean; + readonly secure?: boolean; + readonly supportFetchAPI?: boolean; + readonly corsEnabled?: boolean; + readonly stream?: boolean; + }; + }; + const withPrivilege = ( + schemes: ReadonlyArray, + privilege: keyof NonNullable, + ) => schemes.filter((entry) => entry.privileges?.[privilege]).map((entry) => entry.scheme); + const electron = { + app: { + hasSingleInstanceLock: () => false, + requestSingleInstanceLock: () => true, + releaseSingleInstanceLock: () => undefined, + setAsDefaultProtocolClient: vi.fn(), + on: () => undefined, + removeListener: () => undefined, + quit: () => undefined, + }, + ipcMain: { handle: () => undefined, removeHandler: () => undefined }, + protocol: { + registerSchemesAsPrivileged: (schemes: ReadonlyArray) => { + for (const scheme of withPrivilege(schemes, "standard")) registry.standard.add(scheme); + registry.secure = withPrivilege(schemes, "secure"); + registry.fetch = withPrivilege(schemes, "supportFetchAPI"); + registry.cors = withPrivilege(schemes, "corsEnabled"); + for (const scheme of withPrivilege(schemes, "stream")) registry.stream.add(scheme); + }, + }, + net: {}, + shell: {}, + BrowserWindow: {}, + }; + return { electron, registry }; +}); + +vi.mock("electron", () => ({ ...electron, default: electron })); +vi.mock("@clerk/electron/storage", () => ({ + storage: () => ({ getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }), +})); + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const makeStartupLayer = (isDevelopment: boolean) => { + const environment = DesktopEnvironment.DesktopEnvironment.of({ + stateDir: "/tmp/t3-state", + isDevelopment, + appDataDirectory: "/tmp/app-data", + userDataDirName: isDevelopment ? "t3code-fork-dev" : "t3code-fork", + legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + const electronApp = { + setPath: () => Effect.void, + } as unknown as ElectronApp.ElectronApp["Service"]; + + // main.ts acquires the pre-ready scheme privileges first, then the Clerk bridge. + return DesktopClerk.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), + Layer.succeed(ElectronApp.ElectronApp, electronApp), + FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), + ), + ), + Layer.provideMerge(ElectronProtocol.layerSchemePrivileges), + ); +}; + +describe("DesktopClerk scheme privileges", () => { + beforeEach(() => { + registry.standard.clear(); + registry.secure = []; + registry.fetch = []; + registry.cors = []; + registry.stream.clear(); + electron.app.setAsDefaultProtocolClient.mockClear(); + }); + + for (const { isDevelopment, rendererScheme, linkScheme } of [ + { isDevelopment: false, rendererScheme: "t3code", linkScheme: "t3code-fork" }, + { isDevelopment: true, rendererScheme: "t3code-dev", linkScheme: "t3code-fork-dev" }, + ]) { + it.effect(`keeps ${rendererScheme}://app a secure context once the bridge exists`, () => + Effect.gen(function* () { + yield* Layer.build(makeStartupLayer(isDevelopment)); + + assert.equal(ElectronProtocol.getDesktopScheme(isDevelopment), rendererScheme); + // Without `secure` the window is not a secure context: crypto.subtle is + // undefined there, and T3 Connect cannot make its relay keys. + for (const privilege of ["secure", "fetch", "cors"] as const) { + assert.include(registry[privilege], rendererScheme, privilege); + // The old origin is still loaded once, by the storage migration. + assert.include(registry[privilege], linkScheme, privilege); + } + assert.isTrue(registry.standard.has(rendererScheme)); + assert.isTrue(registry.stream.has(rendererScheme)); + // The bridge still claims only the fork's link scheme from the OS. + assert.deepEqual(electron.app.setAsDefaultProtocolClient.mock.calls, [[linkScheme]]); + }).pipe(Effect.scoped), + ); + } +}); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 78395bac1..6a4a21ed5 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -22,6 +22,12 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); +// DesktopClerk lists the privileged schemes again once the bridge exists. +// DesktopClerk.schemePrivileges.test.ts covers that against the real bridge. +vi.mock("electron", () => ({ + protocol: { registerSchemesAsPrivileged: vi.fn() }, +})); + import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as ElectronApp from "../electron/ElectronApp.ts"; diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 329ac9f9a..e67470632 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -73,7 +73,7 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos ); function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { - return createClerkBridge({ + const bridge = createClerkBridge({ storage: storage({ path: stateDir }), passkeys: true, // Fork: the bridge registers this scheme as the OS link handler and uses it for @@ -85,6 +85,13 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { host: ElectronProtocol.DESKTOP_HOST, }, }); + // Fork: the bridge just registered that link scheme as privileged, and Electron + // keeps the secure, fetch and CORS schemes of the last call only. Upstream passes + // the renderer scheme, so its window stays a secure context. Ours would not: + // t3code://app would lose crypto.subtle, which T3 Connect needs for its relay + // keys. List every scheme again, in the same synchronous step, before `ready`. + ElectronProtocol.registerDesktopSchemePrivilegesSync(); + return bridge; } /** @public Service construction is part of the canonical Effect module API. */ diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index db03825e0..d51221f2f 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -129,11 +129,15 @@ function withContentSecurityPolicy(response: Response, policy: string): Response /** * Must run synchronously during process bootstrap, before Electron emits `ready`. + * + * Fork: Electron keeps the secure, fetch and CORS schemes of the last call only, so + * whoever calls last has to list every scheme. The Clerk bridge makes its own call + * for the link scheme alone, and DesktopClerk runs this again right after. */ -function registerDesktopSchemePrivilegesSync(): void { +export function registerDesktopSchemePrivilegesSync(): void { // Fork: the link schemes are listed too. The storage migration loads a page from // the old t3code-fork://app origin, and web storage only exists on a standard - // scheme. Electron allows this call once, so every scheme belongs in this list. + // scheme. Electron.protocol.registerSchemesAsPrivileged( [ DESKTOP_PRODUCTION_SCHEME, diff --git a/fork-features.json b/fork-features.json index fafee3e13..1d10fd35b 100644 --- a/fork-features.json +++ b/fork-features.json @@ -133,8 +133,8 @@ }, { "name": "fork-connect-renderer-origin", - "description": "T3 Connect sign-in works in the fork. T3's production Clerk instance accepts t3code://app and rejects t3code-fork://app with origin_invalid, so the renderer is served from upstream's t3code scheme while the Clerk bridge, the installer and the Linux desktop entry keep the fork's t3code-fork link scheme. On first launch ForkRendererStorageMigration.ts copies localStorage (drafts, prompt stash, themes, sidebar and second mate folds) from the old t3code-fork://app origin to t3code://app. ForkRendererStorageMigration.ts is the fork's own module and upstream has no file at that path, so its test cannot pass on plain upstream.", - "keep": "ForkRendererStorageMigration.ts is ours whole. getDesktopLinkScheme, the link schemes in the privileged scheme list and the comments on them survive in ElectronProtocol.ts; DesktopClerk.ts, DesktopLinuxUrlHandler.ts and DesktopPreReadyPlatform.ts pass getDesktopLinkScheme where upstream passes getDesktopScheme; the migration call before registerDesktopProtocol survives in DesktopApp.ts; the Google and GitHub sign-in note survives in the doc; everything else in those files takes upstream.", + "description": "T3 Connect sign-in works in the fork. T3's production Clerk instance accepts t3code://app and rejects t3code-fork://app with origin_invalid, so the renderer is served from upstream's t3code scheme while the Clerk bridge, the installer and the Linux desktop entry keep the fork's t3code-fork link scheme. On first launch ForkRendererStorageMigration.ts copies localStorage (drafts, prompt stash, themes, sidebar and second mate folds) from the old t3code-fork://app origin to t3code://app. The Clerk bridge registers its link scheme as privileged on its own, and Electron keeps the secure, fetch and CORS schemes of the last call only, so DesktopClerk.ts lists every scheme again right after creating the bridge. Without that t3code://app is not a secure context, crypto.subtle is undefined and every T3 Connect relay call fails. ForkRendererStorageMigration.ts is the fork's own module and upstream has no file at that path, so its test cannot pass on plain upstream.", + "keep": "ForkRendererStorageMigration.ts is ours whole. getDesktopLinkScheme, the link schemes in the privileged scheme list and the comments on them survive in ElectronProtocol.ts; DesktopClerk.ts, DesktopLinuxUrlHandler.ts and DesktopPreReadyPlatform.ts pass getDesktopLinkScheme where upstream passes getDesktopScheme; registerDesktopSchemePrivilegesSync stays exported from ElectronProtocol.ts and DesktopClerk.ts calls it right after createClerkBridge; the migration call before registerDesktopProtocol survives in DesktopApp.ts; the Google and GitHub sign-in note survives in the doc; everything else in those files takes upstream.", "files": [ "apps/desktop/src/app/ForkRendererStorageMigration.ts", "apps/desktop/src/electron/ElectronProtocol.ts", @@ -146,7 +146,8 @@ ], "test": [ "apps/desktop/src/app/ForkRendererStorageMigration.test.ts", - "apps/desktop/src/electron/ElectronProtocol.test.ts" + "apps/desktop/src/electron/ElectronProtocol.test.ts", + "apps/desktop/src/app/DesktopClerk.schemePrivileges.test.ts" ], "patchesUpstream": true },