From cbaa168d6b51d3c2bff11bd9d0b82e944c62e50f Mon Sep 17 00:00:00 2001 From: Theo Tarr Date: Thu, 21 May 2026 15:19:54 -0400 Subject: [PATCH] Unbundle Chromium auth browser --- scripts/README.md | 3 +- scripts/build-cued-daemon-app.sh | 17 +---- scripts/build-cued-release-artifacts.sh | 11 +-- scripts/fetch-playwright-chromium-macos.sh | 38 ---------- scripts/validate-cued-release-artifact.sh | 1 + .../core/auth/browser-resolver.test.ts | 64 ++++++++++++++++ src/platforms/core/auth/browser-resolver.ts | 75 +++++++++++++++++++ src/platforms/core/auth/chromium-worker.ts | 48 ++++++++---- 8 files changed, 180 insertions(+), 77 deletions(-) delete mode 100644 scripts/fetch-playwright-chromium-macos.sh create mode 100644 src/platforms/core/auth/browser-resolver.test.ts create mode 100644 src/platforms/core/auth/browser-resolver.ts diff --git a/scripts/README.md b/scripts/README.md index dde2671a..a7f0ee30 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,7 +14,6 @@ Local build, packaging, and macOS permission helpers for the packaged Cued runti | `install-cued-release.sh` | Install the latest GitHub release from the terminal. | | `request-macos-access.sh` | Trigger or open the relevant macOS privacy prompts and panes. | | `fetch-node-runtime-macos.sh` | Fetch the Node runtime bundled into the app. | -| `fetch-playwright-chromium-macos.sh` | Fetch the Chromium payload bundled into the app. | | `fetch-signal-cli-macos.sh` | Fetch the Signal helper payload bundled into the app. | ## App bundle build @@ -28,7 +27,7 @@ This script builds the packaged development app at `native/macos/dist/Cued.app`. - rebuilds the root TypeScript runtime - rebuilds the native Swift host - stages production Node dependencies -- bundles the Node runtime and Chromium payload +- bundles the Node runtime - bundles helper binaries such as the native macOS helper, Signal helper, and WhatsApp helper - signs the app bundle for local execution diff --git a/scripts/build-cued-daemon-app.sh b/scripts/build-cued-daemon-app.sh index 85566cd5..72049ab8 100644 --- a/scripts/build-cued-daemon-app.sh +++ b/scripts/build-cued-daemon-app.sh @@ -17,13 +17,10 @@ RESOURCES_DIR="$CONTENTS_DIR/Resources" RUNTIME_DIR="$RESOURCES_DIR/cued-runtime" RUNTIME_NODE_ROOT="$RESOURCES_DIR/runtime/node" RUNTIME_NODE_BIN_DIR="$RUNTIME_NODE_ROOT/bin" -PLAYWRIGHT_CHROMIUM_ROOT="$RESOURCES_DIR/runtime/chromium" -PLAYWRIGHT_CHROMIUM_PAYLOAD_DIR="$PLAYWRIGHT_CHROMIUM_ROOT/chrome" HELPERS_DIR="$RESOURCES_DIR/helpers" HELPER_NAME="cued-native-helper" SIGNAL_FETCH_SCRIPT="$ROOT_DIR/scripts/fetch-signal-cli-macos.sh" NODE_RUNTIME_FETCH_SCRIPT="$ROOT_DIR/scripts/fetch-node-runtime-macos.sh" -PLAYWRIGHT_CHROMIUM_FETCH_SCRIPT="$ROOT_DIR/scripts/fetch-playwright-chromium-macos.sh" JIT_RUNTIME_ENTITLEMENTS="$ROOT_DIR/scripts/packaging/jit-runtime.entitlements.plist" APP_PERMISSIONS_ENTITLEMENTS="$ROOT_DIR/scripts/packaging/app-permissions.entitlements.plist" SIGNAL_HELPER_SOURCE_DIR="$ROOT_DIR/native/helpers/signal-cli/.build/cued-signal-cli" @@ -127,20 +124,14 @@ runtime_entitlements_for_binary() { "$HELPERS_DIR/$HELPER_NAME") printf '%s\n' "$APP_PERMISSIONS_ENTITLEMENTS" ;; - "$RUNTIME_NODE_BIN_DIR/node"|"$HELPERS_DIR"/signal-cli/jre/Contents/Home/bin/java|"$PLAYWRIGHT_CHROMIUM_ROOT"/*) + "$RUNTIME_NODE_BIN_DIR/node"|"$HELPERS_DIR"/signal-cli/jre/Contents/Home/bin/java) printf '%s\n' "$JIT_RUNTIME_ENTITLEMENTS" ;; esac } runtime_entitlements_for_bundle() { - local target="$1" - - case "$target" in - "$PLAYWRIGHT_CHROMIUM_ROOT"/*) - printf '%s\n' "$JIT_RUNTIME_ENTITLEMENTS" - ;; - esac + : } sign_nested_binaries() { @@ -234,7 +225,6 @@ pnpm --dir "$ROOT_DIR" build >/dev/null swift build --package-path "$SWIFT_PACKAGE_DIR" -c release >/dev/null SWIFT_RESOURCE_BUNDLES=("$SWIFT_PACKAGE_DIR"/.build/*/release/*.bundle) NODE_RUNTIME_SOURCE_DIR="$(bash "$NODE_RUNTIME_FETCH_SCRIPT" "$NODE_VERSION" "$NODE_ARCH")" -PLAYWRIGHT_CHROMIUM_SOURCE_DIR="$(bash "$PLAYWRIGHT_CHROMIUM_FETCH_SCRIPT")" bash "$SIGNAL_FETCH_SCRIPT" >/dev/null mkdir -p "$(dirname "$SLACK_HELPER_SOURCE")" (cd "$ROOT_DIR/native/helpers/slack-go" && GOWORK=off go build -o "$SLACK_HELPER_SOURCE" .) >/dev/null @@ -249,7 +239,6 @@ mkdir -p \ "$RESOURCES_DIR" \ "$RUNTIME_DIR" \ "$RUNTIME_NODE_BIN_DIR" \ - "$PLAYWRIGHT_CHROMIUM_ROOT" \ "$HELPERS_DIR" ditto --noextattr --norsrc "$SWIFT_BINARY" "$MACOS_DIR/$APP_EXECUTABLE_NAME" @@ -263,7 +252,6 @@ done cp "$NODE_RUNTIME_SOURCE_DIR/bin/node" "$RUNTIME_NODE_BIN_DIR/node" cp -R "$NODE_RUNTIME_SOURCE_DIR/lib" "$RUNTIME_NODE_ROOT/lib" chmod +x "$RUNTIME_NODE_BIN_DIR/node" -ditto --noextattr --norsrc "$PLAYWRIGHT_CHROMIUM_SOURCE_DIR" "$PLAYWRIGHT_CHROMIUM_PAYLOAD_DIR" cp -R "$DEPLOY_STAGING_DIR/." "$RUNTIME_DIR/" cp -R "$SIGNAL_HELPER_SOURCE_DIR" "$HELPERS_DIR/signal-cli" cp "$SLACK_HELPER_SOURCE" "$HELPERS_DIR/cued-slack-helper" @@ -412,7 +400,6 @@ export CUED_NATIVE_BINARY="\${CUED_NATIVE_BINARY:-\$HELPER_BINARY}" export CUED_IMESSAGE_NATIVE_BINARY="\${CUED_IMESSAGE_NATIVE_BINARY:-\$HELPER_BINARY}" export CUED_CONTACTS_NATIVE_BINARY="\${CUED_CONTACTS_NATIVE_BINARY:-\$HELPER_BINARY}" export CUED_AUTH_NATIVE_BINARY="\${CUED_AUTH_NATIVE_BINARY:-\$APP_EXEC}" -export CUED_CHROMIUM_EXECUTABLE_PATH="\${CUED_CHROMIUM_EXECUTABLE_PATH:-\$SCRIPT_DIR/runtime/chromium/chrome/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing}" if [[ -f "\$SCRIPT_DIR/oauth/google-oauth-client.json" ]]; then export CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE="\${CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE:-\$SCRIPT_DIR/oauth/google-oauth-client.json}" fi diff --git a/scripts/build-cued-release-artifacts.sh b/scripts/build-cued-release-artifacts.sh index e6807c8f..f2ddd9d5 100644 --- a/scripts/build-cued-release-artifacts.sh +++ b/scripts/build-cued-release-artifacts.sh @@ -33,21 +33,14 @@ runtime_entitlements_for_binary() { printf '%s\n' "$APP_PERMISSIONS_ENTITLEMENTS" ;; "$APP_BUNDLE/Contents/Resources/runtime/node/bin/node"|\ - "$APP_BUNDLE/Contents/Resources/helpers/signal-cli/jre/Contents/Home/bin/java"|\ - "$APP_BUNDLE/Contents/Resources/runtime/chromium"/*) + "$APP_BUNDLE/Contents/Resources/helpers/signal-cli/jre/Contents/Home/bin/java") printf '%s\n' "$JIT_RUNTIME_ENTITLEMENTS" ;; esac } runtime_entitlements_for_bundle() { - local target="$1" - - case "$target" in - "$APP_BUNDLE/Contents/Resources/runtime/chromium"/*) - printf '%s\n' "$JIT_RUNTIME_ENTITLEMENTS" - ;; - esac + : } sign_macos_binary() { diff --git a/scripts/fetch-playwright-chromium-macos.sh b/scripts/fetch-playwright-chromium-macos.sh deleted file mode 100644 index bac27ee8..00000000 --- a/scripts/fetch-playwright-chromium-macos.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -NODE_BIN="${CUED_NODE_PATH:-$(command -v node)}" -PLAYWRIGHT_CLI="$ROOT_DIR/node_modules/.bin/playwright" -PLAYWRIGHT_CACHE_DIR="${CUED_PLAYWRIGHT_BROWSER_CACHE_DIR:-${TMPDIR:-/tmp}/cued-playwright-browsers}" - -if [[ ! -x "$PLAYWRIGHT_CLI" ]]; then - echo "Playwright CLI not found at $PLAYWRIGHT_CLI" >&2 - exit 1 -fi - -mkdir -p "$PLAYWRIGHT_CACHE_DIR" - -PLAYWRIGHT_BROWSERS_PATH="$PLAYWRIGHT_CACHE_DIR" \ - PLAYWRIGHT_SKIP_BROWSER_GC=1 \ - "$PLAYWRIGHT_CLI" install chromium >/dev/null - -PLAYWRIGHT_EXECUTABLE_PATH="$( - cd "$ROOT_DIR" - PLAYWRIGHT_BROWSERS_PATH="$PLAYWRIGHT_CACHE_DIR" \ - "$NODE_BIN" -e 'const { chromium } = require("playwright"); process.stdout.write(chromium.executablePath());' -)" - -if [[ -z "$PLAYWRIGHT_EXECUTABLE_PATH" || ! -x "$PLAYWRIGHT_EXECUTABLE_PATH" ]]; then - echo "Could not resolve the installed Playwright Chromium executable" >&2 - exit 1 -fi - -PLAYWRIGHT_PAYLOAD_DIR="$(cd "$(dirname "$PLAYWRIGHT_EXECUTABLE_PATH")/../../.." && pwd)" -if [[ ! -d "$PLAYWRIGHT_PAYLOAD_DIR" ]]; then - echo "Could not resolve the installed Playwright Chromium payload directory" >&2 - exit 1 -fi - -printf '%s\n' "$PLAYWRIGHT_PAYLOAD_DIR" diff --git a/scripts/validate-cued-release-artifact.sh b/scripts/validate-cued-release-artifact.sh index 17d0c252..40f137c8 100755 --- a/scripts/validate-cued-release-artifact.sh +++ b/scripts/validate-cued-release-artifact.sh @@ -45,6 +45,7 @@ for executable_path in \ done for forbidden_path in \ + "$RESOURCES_PATH/runtime/chromium" \ "$RUNTIME_PATH/scripts" \ "$RUNTIME_PATH/src" \ "$RUNTIME_PATH/native" \ diff --git a/src/platforms/core/auth/browser-resolver.test.ts b/src/platforms/core/auth/browser-resolver.test.ts new file mode 100644 index 00000000..b4557e67 --- /dev/null +++ b/src/platforms/core/auth/browser-resolver.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + type BrowserCandidate, + formatMissingChromiumAuthBrowserError, + listInstalledChromiumAuthBrowsers, + resolveChromiumAuthBrowser, +} from "./browser-resolver.js"; + +describe("resolveChromiumAuthBrowser", () => { + const candidates: BrowserCandidate[] = [ + { + name: "Missing", + executablePath: "/definitely/not/installed", + appBundleIdentifier: "missing.browser", + }, + { + name: "Shell", + executablePath: "/bin/sh", + appBundleIdentifier: "test.shell", + }, + ]; + + it("uses an explicit executable path override", () => { + expect( + resolveChromiumAuthBrowser( + { + CUED_CHROMIUM_EXECUTABLE_PATH: "/custom/browser", + CUED_CHROMIUM_APP_BUNDLE_IDENTIFIER: "custom.browser", + }, + candidates, + ), + ).toEqual({ + name: "Configured Chromium browser", + executablePath: "/custom/browser", + appBundleIdentifier: "custom.browser", + }); + }); + + it("falls back to the first installed candidate", () => { + expect(resolveChromiumAuthBrowser({}, candidates)).toEqual(candidates[1]); + }); + + it("lists every installed fallback candidate in order", () => { + const installedCandidates = [ + candidates[0]!, + candidates[1]!, + { + name: "Also Shell", + executablePath: "/bin/sh", + appBundleIdentifier: "test.shell-alt", + }, + ]; + + expect(listInstalledChromiumAuthBrowsers({}, installedCandidates)).toEqual([ + candidates[1], + installedCandidates[2], + ]); + }); + + it("returns null when no candidate exists", () => { + expect(resolveChromiumAuthBrowser({}, [candidates[0]!])).toBeNull(); + expect(formatMissingChromiumAuthBrowserError()).toContain("No supported browser"); + }); +}); diff --git a/src/platforms/core/auth/browser-resolver.ts b/src/platforms/core/auth/browser-resolver.ts new file mode 100644 index 00000000..9950f9de --- /dev/null +++ b/src/platforms/core/auth/browser-resolver.ts @@ -0,0 +1,75 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface BrowserCandidate { + name: string; + executablePath: string; + appBundleIdentifier: string; +} + +function appExecutablePath(appRoot: string, appName: string, executableName: string): string { + return join(appRoot, `${appName}.app`, "Contents", "MacOS", executableName); +} + +function macOSBrowserCandidatesForAppRoot(appRoot: string): BrowserCandidate[] { + return [ + { + name: "Google Chrome", + executablePath: appExecutablePath(appRoot, "Google Chrome", "Google Chrome"), + appBundleIdentifier: "com.google.Chrome", + }, + { + name: "Chromium", + executablePath: appExecutablePath(appRoot, "Chromium", "Chromium"), + appBundleIdentifier: "org.chromium.Chromium", + }, + { + name: "Microsoft Edge", + executablePath: appExecutablePath(appRoot, "Microsoft Edge", "Microsoft Edge"), + appBundleIdentifier: "com.microsoft.edgemac", + }, + { + name: "Brave Browser", + executablePath: appExecutablePath(appRoot, "Brave Browser", "Brave Browser"), + appBundleIdentifier: "com.brave.Browser", + }, + ]; +} + +export const MACOS_BROWSER_CANDIDATES: BrowserCandidate[] = [ + ...macOSBrowserCandidatesForAppRoot("/Applications"), + ...macOSBrowserCandidatesForAppRoot(join(homedir(), "Applications")), +]; + +export function listInstalledChromiumAuthBrowsers( + env: NodeJS.ProcessEnv = process.env, + candidates: BrowserCandidate[] = MACOS_BROWSER_CANDIDATES, +): BrowserCandidate[] { + const configured = env.CUED_CHROMIUM_EXECUTABLE_PATH?.trim(); + if (configured) { + return [ + { + name: "Configured Chromium browser", + executablePath: configured, + appBundleIdentifier: env.CUED_CHROMIUM_APP_BUNDLE_IDENTIFIER?.trim() || "com.google.Chrome", + }, + ]; + } + + return candidates.filter((candidate) => existsSync(candidate.executablePath)); +} + +export function resolveChromiumAuthBrowser( + env: NodeJS.ProcessEnv = process.env, + candidates: BrowserCandidate[] = MACOS_BROWSER_CANDIDATES, +): BrowserCandidate | null { + return listInstalledChromiumAuthBrowsers(env, candidates)[0] ?? null; +} + +export function formatMissingChromiumAuthBrowserError(): string { + return [ + "No supported browser was found for Cued browser authentication.", + "Install Google Chrome, Chromium, Microsoft Edge, or Brave Browser and try again.", + ].join(" "); +} diff --git a/src/platforms/core/auth/chromium-worker.ts b/src/platforms/core/auth/chromium-worker.ts index a3b68def..b96dcf7d 100644 --- a/src/platforms/core/auth/chromium-worker.ts +++ b/src/platforms/core/auth/chromium-worker.ts @@ -2,6 +2,11 @@ import { execFileSync } from "node:child_process"; import { mkdirSync } from "node:fs"; import { type BrowserContext, type Cookie, chromium, type Page, type Request } from "playwright"; import { cuedAuthKeychainService } from "../../../core/identity.js"; +import { + type BrowserCandidate, + formatMissingChromiumAuthBrowserError, + listInstalledChromiumAuthBrowsers, +} from "./browser-resolver.js"; declare const localStorage: { getItem(key: string): string | null; @@ -74,15 +79,11 @@ function getTimeoutMs(): number { return Number.isFinite(configured) && configured > 0 ? configured : 15 * 60_000; } -function getExecutablePath(): string | undefined { - return process.env.CUED_CHROMIUM_EXECUTABLE_PATH || undefined; -} - -function bringAuthBrowserToFront(): void { +function bringAuthBrowserToFront(browser: BrowserCandidate): void { try { execFileSync( "osascript", - ["-e", 'tell application id "com.google.chrome.for.testing" to activate'], + ["-e", `tell application id "${browser.appBundleIdentifier}" to activate`], { stdio: ["ignore", "ignore", "ignore"], }, @@ -475,21 +476,42 @@ async function run(): Promise { } mkdirSync(args.profileDir, { recursive: true }); + const browsers = listInstalledChromiumAuthBrowsers(); + if (browsers.length === 0) { + throw new Error(formatMissingChromiumAuthBrowserError()); + } + let context: BrowserContext | null = null; + let browser: BrowserCandidate | null = null; + const launchErrors: string[] = []; try { - context = await chromium.launchPersistentContext(args.profileDir, { - headless: false, - executablePath: getExecutablePath(), - args: ["--disable-blink-features=AutomationControlled"], - viewport: { width: 1280, height: 900 }, - }); + for (const candidate of browsers) { + try { + context = await chromium.launchPersistentContext(args.profileDir, { + headless: false, + executablePath: candidate.executablePath, + args: ["--disable-blink-features=AutomationControlled"], + viewport: { width: 1280, height: 900 }, + }); + browser = candidate; + break; + } catch (error) { + launchErrors.push( + `${candidate.name}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + if (!context || !browser) { + throw new Error(`Could not launch a supported browser. ${launchErrors.join(" ")}`); + } let page = firstOpenPage(context) ?? (await context.newPage()); if (!page.url() || page.url() === "about:blank") { await page.goto(args.launchTarget, { waitUntil: "domcontentloaded" }); } await page.bringToFront().catch(() => {}); - bringAuthBrowserToFront(); + bringAuthBrowserToFront(browser); const deadline = Date.now() + getTimeoutMs(); let lastObservedUrls: string[] = [];