Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions apps/desktop/src/app/DesktopClerk.schemePrivileges.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(),
secure: [] as string[],
fetch: [] as string[],
cors: [] as string[],
stream: new Set<string>(),
};
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<CustomScheme>,
privilege: keyof NonNullable<CustomScheme["privileges"]>,
) => 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<CustomScheme>) => {
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<string>) => 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),
);
}
});
6 changes: 6 additions & 0 deletions apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions fork-features.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
},
Expand Down
Loading