Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
branches:
- main

permissions:
contents: read

concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
Expand Down Expand Up @@ -52,10 +55,7 @@ jobs:
run: vp run build:desktop

- name: Verify preload bundle output
run: |
test -f apps/desktop/dist-electron/preload.cjs
grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs
grep -n "__clerk_internal_electron_passkeys" apps/desktop/dist-electron/preload.cjs
run: node apps/desktop/scripts/verify-preload-bundle.mjs
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Everything except `t3` (apps/server). `--parallel` drops the package
# dependency ordering that `vp run` applies by default: these `test` tasks
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"devDependencies": {
"@effect/vitest": "catalog:",
"@types/node": "catalog:",
"acorn": "8.16.0",
"cross-env": "^10.1.0",
"electron-builder": "26.15.6",
"tailwindcss": "^4.0.0",
Expand Down
113 changes: 113 additions & 0 deletions apps/desktop/scripts/verify-preload-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as NodeFSP from "node:fs/promises";
import * as NodeURL from "node:url";
import { parse } from "acorn";

const expectedDesktopBridgeApis = [
"getClientPlatform",
"getLocalEnvironmentBootstraps",
"pickFolder",
];
const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys";

const isSyntaxNode = (value) =>
typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";

const readStaticName = (node) => {
if (node.type === "Identifier") return node.name;
if (node.type === "Literal" && typeof node.value === "string") return node.value;
return undefined;
};

const readMemberName = (node) => {
if (node.type !== "MemberExpression") return undefined;
if (!node.computed && node.property.type === "Identifier") return node.property.name;
return readStaticName(node.property);
};

const isContextBridge = (node) =>
(node.type === "Identifier" && node.name === "contextBridge") ||
(node.type === "MemberExpression" && readMemberName(node) === "contextBridge");

const inspectBundle = (source) => {
const runtimeImports = [];
const exposedGlobals = new Set();
const desktopBridgeApis = new Set();
const visit = (node) => {
if (node.type === "ImportExpression") {
throw new Error("Desktop preload bundle contains a dynamic import() call");
}

if (node.type === "CallExpression" && node.callee.type === "Identifier") {
Comment thread
t3dotgg marked this conversation as resolved.
if (node.callee.name === "require") {
const [argument] = node.arguments;
if (node.arguments.length !== 1 || argument?.type !== "Literal") {
throw new Error("Desktop preload bundle contains a dynamic require() call");
}
if (typeof argument.value !== "string") {
throw new Error("Desktop preload bundle contains a dynamic require() call");
}
runtimeImports.push(argument.value);
}
}

if (
node.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
readMemberName(node.callee) === "exposeInMainWorld" &&
isContextBridge(node.callee.object)
) {
const [globalName, api] = node.arguments;
const name = globalName === undefined ? undefined : readStaticName(globalName);
if (name !== undefined) exposedGlobals.add(name);

if (name === "desktopBridge" && api?.type === "ObjectExpression") {
for (const property of api.properties) {
if (property.type !== "Property") continue;
const propertyName = readStaticName(property.key);
if (propertyName !== undefined) desktopBridgeApis.add(propertyName);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}
}
}

for (const child of Object.values(node)) {
if (Array.isArray(child)) {
for (const item of child) {
if (isSyntaxNode(item)) visit(item);
}
} else if (isSyntaxNode(child)) {
visit(child);
}
}
};

visit(parse(source, { ecmaVersion: "latest", sourceType: "script" }));
return { desktopBridgeApis, exposedGlobals, runtimeImports };
};

export const verifyPreloadBundle = (source) => {
const { desktopBridgeApis, exposedGlobals, runtimeImports } = inspectBundle(source);
const missingApis = expectedDesktopBridgeApis.filter((api) => !desktopBridgeApis.has(api));
if (!exposedGlobals.has("desktopBridge")) missingApis.unshift("desktopBridge exposure");
if (!exposedGlobals.has(clerkPasskeysGlobal)) missingApis.push(`${clerkPasskeysGlobal} exposure`);

if (missingApis.length > 0) {
throw new Error(`Desktop preload bundle is missing executable APIs: ${missingApis.join(", ")}`);
}

const sandboxModules = new Set(["electron", "events", "timers", "url"]);
const unsupportedImports = [...new Set(runtimeImports)]
.filter((moduleName) => !sandboxModules.has(moduleName))
.toSorted();

if (unsupportedImports.length > 0) {
throw new Error(
`Desktop preload bundle contains unsupported sandbox imports: ${unsupportedImports.join(", ")}`,
);
}
};

if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) {
const preloadUrl = new URL("../dist-electron/preload.cjs", import.meta.url);
const source = await NodeFSP.readFile(preloadUrl, "utf8");
verifyPreloadBundle(source);
}
66 changes: 66 additions & 0 deletions apps/desktop/scripts/verify-preload-bundle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { assert, describe, it } from "vite-plus/test";

import { verifyPreloadBundle } from "./verify-preload-bundle.mjs";

const validPreload = `
const electron = require("electron");
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
electron.contextBridge.exposeInMainWorld("__clerk_internal_electron_passkeys", {});
electron.contextBridge.exposeInMainWorld("desktopBridge", {
getClientPlatform: () => process.platform,
getLocalEnvironmentBootstraps: () => [],
pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
});
`;

describe("desktop preload bundle verifier", () => {
it("rejects required API names that only appear in strings", () => {
assert.throws(
() =>
verifyPreloadBundle(`
"desktopBridge getClientPlatform getLocalEnvironmentBootstraps pickFolder";
"__clerk_internal_electron_passkeys";
require("electron");
`),
/missing executable APIs/,
);
});

it("rejects dynamic imports with comments before the opening parenthesis", () => {
assert.throws(
() =>
verifyPreloadBundle(`${validPreload}\nimport /* @vite-ignore */("unsupported-module");`),
/dynamic import\(\)/,
);
});

it("ignores import-like text in strings", () => {
assert.doesNotThrow(() =>
verifyPreloadBundle(`${validPreload}\nconst message = 'import /* comment */("module")';`),
);
});

it("rejects unsupported require calls with comments before the opening parenthesis", () => {
assert.throws(
() => verifyPreloadBundle(`${validPreload}\nrequire /* @__PURE__ */ ("node:fs");`),
/unsupported sandbox imports: node:fs/,
);
});

it("rejects unsupported optional require calls", () => {
assert.throws(
() => verifyPreloadBundle(`${validPreload}\nrequire?.("node:fs");`),
/unsupported sandbox imports: node:fs/,
);
});

it("ignores require-like text in strings and comments", () => {
assert.doesNotThrow(() =>
verifyPreloadBundle(`
${validPreload}
const message = 'require("node:fs")';
// require("node:path")
`),
);
});
});
4 changes: 4 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import * as IpcChannels from "./ipc/channels.ts";

exposeClerkBridge({ passkeys: true });

// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process.
const clientPlatform = process.platform;

function unwrapEnsureSshEnvironmentResult(result: unknown) {
if (
typeof result === "object" &&
Expand All @@ -35,6 +38,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
getClientPlatform: () => clientPlatform,
getSystemLocale: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
return typeof result === "string" ? result : null;
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/features/cloud/linkEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ vi.mock("expo-constants", () => ({
}));

vi.mock("expo-device", () => ({
deviceType: 1,
DeviceType: {
UNKNOWN: 0,
PHONE: 1,
TABLET: 2,
DESKTOP: 3,
TV: 4,
},
osVersion: "18.4.1",
modelName: "iPhone 15 Pro",
}));
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/lib/authClientMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM

return {
label: "T3 Code Mobile",
deviceType: "mobile",
deviceType:
Device.deviceType === Device.DeviceType.TABLET
? "tablet"
: Device.deviceType === Device.DeviceType.PHONE
? "mobile"
: "unknown",
...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}),
...(deviceModel ? { deviceModel } : {}),
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/lib/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import { authClientMetadata } from "./authClientMetadata";

const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" }));
const mobileDevice = vi.hoisted(() => ({
deviceType: 1,
DeviceType: {
UNKNOWN: 0,
PHONE: 1,
TABLET: 2,
DESKTOP: 3,
TV: 4,
},
osVersion: "18.4.1",
modelName: "iPhone 15 Pro",
}));
Expand All @@ -29,6 +37,7 @@ vi.mock("expo-device", () => mobileDevice);
describe("mobile remote connection records", () => {
afterEach(() => {
mobilePlatform.OS = "ios";
mobileDevice.deviceType = mobileDevice.DeviceType.PHONE;
mobileDevice.osVersion = "18.4.1";
mobileDevice.modelName = "iPhone 15 Pro";
});
Expand Down Expand Up @@ -56,6 +65,17 @@ describe("mobile remote connection records", () => {
});
});

it("identifies native tablets separately from phones", () => {
mobileDevice.deviceType = mobileDevice.DeviceType.TABLET;
mobileDevice.modelName = "iPad Pro 13-inch";

expect(authClientMetadata()).toMatchObject({
deviceType: "tablet",
os: "iOS",
deviceModel: "iPad Pro 13-inch",
});
});

it("includes the mobile app version when the client provides it", () => {
expect(authClientMetadata("1.2.3")).toMatchObject({
surface: "mobile",
Expand Down
Loading
Loading