Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import {
installMacOSApp,
resolveInstalledAppPath,
} from "./macos/install.js";
import {
getManagedChromiumStatus,
installManagedChromiumBrowser,
} from "./platforms/core/auth/managed-browser.js";
import { IntegrationAuthService } from "./platforms/core/auth/service.js";
import {
getIntegrationSummary,
Expand Down Expand Up @@ -142,6 +146,7 @@ Usage:
cued login-item enable|disable|status
cued onboarding complete|snapshot|status [--refresh-managed] [--refresh-permissions]
cued telemetry status|enable|disable|smoke
cued browser status|install
cued skill install-global|status
cued permissions doctor|status|request [--all|--contacts|--full-disk-access]
cued sql <query>
Expand Down Expand Up @@ -777,6 +782,17 @@ async function main(): Promise<void> {
default:
throw new Error("Usage: cued skill install-global | status");
}
case "browser":
switch (subcommand) {
case "status":
printJson(getManagedChromiumStatus());
return;
case "install":
printJson(await installManagedChromiumBrowser());
return;
default:
throw new Error("Usage: cued browser status | install");
}
case "telemetry": {
const db = openCuedDatabase();
try {
Expand Down
6 changes: 6 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const CUED_MENU_BAR_STATUS_PATH = join(CUED_HOME, "menu-bar-status.json")
export const CUED_LOG_DIR = join(CUED_HOME, "logs");
export const CUED_DAEMON_LOG_PATH = join(CUED_LOG_DIR, "daemon.log");
export const CUED_BROWSER_DIR = join(CUED_HOME, "browser");
export const CUED_MANAGED_BROWSER_DIR = join(CUED_HOME, "browsers");
export const CUED_INTEGRATIONS_DIR = join(CUED_HOME, "integrations");
export const CUED_SIGNAL_DIR = join(CUED_INTEGRATIONS_DIR, "signal");
export const CUED_WHATSAPP_DIR = join(CUED_INTEGRATIONS_DIR, "whatsapp");
Expand All @@ -59,6 +60,10 @@ export function ensureCuedDirs(): void {
mkdirSync(CUED_BROWSER_DIR, { recursive: true, mode: 0o700 });
}

if (!existsSync(CUED_MANAGED_BROWSER_DIR)) {
mkdirSync(CUED_MANAGED_BROWSER_DIR, { recursive: true, mode: 0o700 });
}

if (!existsSync(CUED_INTEGRATIONS_DIR)) {
mkdirSync(CUED_INTEGRATIONS_DIR, { recursive: true, mode: 0o700 });
}
Expand Down Expand Up @@ -102,6 +107,7 @@ export function ensureCuedDirs(): void {
// Best-effort hardening for the local data dir.
chmodSync(CUED_HOME, 0o700);
chmodSync(CUED_BROWSER_DIR, 0o700);
chmodSync(CUED_MANAGED_BROWSER_DIR, 0o700);
chmodSync(CUED_INTEGRATIONS_DIR, 0o700);
chmodSync(CUED_SIGNAL_DIR, 0o700);
chmodSync(CUED_WHATSAPP_DIR, 0o700);
Expand Down
57 changes: 55 additions & 2 deletions src/platforms/core/auth/browser-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
type BrowserCandidate,
formatMissingChromiumAuthBrowserError,
listAvailableChromiumAuthBrowsers,
listInstalledChromiumAuthBrowsers,
resolveChromiumAuthBrowser,
} from "./browser-resolver.js";
import { installManagedChromiumBrowser } from "./managed-browser.js";

describe("resolveChromiumAuthBrowser", () => {
const tempDirs: string[] = [];
const candidates: BrowserCandidate[] = [
{
name: "Missing",
Expand All @@ -20,6 +26,38 @@ describe("resolveChromiumAuthBrowser", () => {
},
];

afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop()!, { recursive: true, force: true });
}
});

function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}

async function installFakeManagedBrowser(root: string): Promise<BrowserCandidate> {
return installManagedChromiumBrowser({
env: { CUED_MANAGED_BROWSER_DIR: root },
installChromium: async ({ destinationRoot }) => {
const executablePath = join(
destinationRoot,
"chromium",
"Chromium.app",
"Contents",
"MacOS",
"Chromium",
);
mkdirSync(dirname(executablePath), { recursive: true });
writeFileSync(executablePath, "");
},
resolveExecutablePath: async ({ browserRoot }) =>
join(browserRoot, "chromium", "Chromium.app", "Contents", "MacOS", "Chromium"),
});
}

it("uses an explicit executable path override", () => {
expect(
resolveChromiumAuthBrowser(
Expand Down Expand Up @@ -57,8 +95,23 @@ describe("resolveChromiumAuthBrowser", () => {
]);
});

it("appends an installed managed Chromium after local browsers", async () => {
const managedRoot = tempDir("cued-managed-browser-resolver-");
const managedBrowser = await installFakeManagedBrowser(managedRoot);

expect(
listAvailableChromiumAuthBrowsers({ CUED_MANAGED_BROWSER_DIR: managedRoot }, [
candidates[0]!,
candidates[1]!,
]),
).toEqual([candidates[1], managedBrowser]);
});

it("returns null when no candidate exists", () => {
expect(resolveChromiumAuthBrowser({}, [candidates[0]!])).toBeNull();
const managedRoot = tempDir("cued-managed-browser-missing-");
expect(
resolveChromiumAuthBrowser({ CUED_MANAGED_BROWSER_DIR: managedRoot }, [candidates[0]!]),
).toBeNull();
expect(formatMissingChromiumAuthBrowserError()).toContain("No supported browser");
});
});
21 changes: 18 additions & 3 deletions src/platforms/core/auth/browser-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { getManagedChromiumBrowser } from "./managed-browser.js";

export interface BrowserCandidate {
name: string;
Expand Down Expand Up @@ -60,16 +61,30 @@ export function listInstalledChromiumAuthBrowsers(
return candidates.filter((candidate) => existsSync(candidate.executablePath));
}

export function listAvailableChromiumAuthBrowsers(
env: NodeJS.ProcessEnv = process.env,
candidates: BrowserCandidate[] = MACOS_BROWSER_CANDIDATES,
): BrowserCandidate[] {
const installed = listInstalledChromiumAuthBrowsers(env, candidates);
let managed: BrowserCandidate | null = null;
try {
managed = getManagedChromiumBrowser(env);
} catch {
managed = null;
}
return managed ? [...installed, managed] : installed;
}

export function resolveChromiumAuthBrowser(
env: NodeJS.ProcessEnv = process.env,
candidates: BrowserCandidate[] = MACOS_BROWSER_CANDIDATES,
): BrowserCandidate | null {
return listInstalledChromiumAuthBrowsers(env, candidates)[0] ?? null;
return listAvailableChromiumAuthBrowsers(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.",
"No supported browser was available for Cued browser authentication.",
"Install Google Chrome, Chromium, Microsoft Edge, or Brave Browser, or run `cued browser install` to install Cued's managed Chromium.",
].join(" ");
}
49 changes: 37 additions & 12 deletions src/platforms/core/auth/chromium-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { cuedAuthKeychainService } from "../../../core/identity.js";
import {
type BrowserCandidate,
formatMissingChromiumAuthBrowserError,
listInstalledChromiumAuthBrowsers,
listAvailableChromiumAuthBrowsers,
} from "./browser-resolver.js";
import { installManagedChromiumBrowser } from "./managed-browser.js";

declare const localStorage: {
getItem(key: string): string | null;
Expand Down Expand Up @@ -49,12 +50,18 @@ const REQUIRED_ARGS = new Set([
"--launch-target",
]);

const DEFAULT_LAUNCH_TARGETS: Record<Platform, string> = {
slack: "https://slack.com/signin",
linkedin: "https://www.linkedin.com/login",
discord: "https://discord.com/login",
};

function parseArgs(argv: string[]): WorkerArgs {
const values = new Map<string, string>();
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index];
const value = argv[index + 1];
if (!REQUIRED_ARGS.has(key) || !value) {
if (!REQUIRED_ARGS.has(key) || value == null) {
throw new Error(`Invalid argument sequence at ${key ?? "end"}`);
}
values.set(key, value);
Expand All @@ -70,7 +77,7 @@ function parseArgs(argv: string[]): WorkerArgs {
accountKey: values.get("--account-key")!,
sessionId: values.get("--session-id")!,
profileDir: values.get("--profile-dir")!,
launchTarget: values.get("--launch-target")!,
launchTarget: values.get("--launch-target") || DEFAULT_LAUNCH_TARGETS[platform],
};
}

Expand Down Expand Up @@ -467,6 +474,18 @@ function getFakeResult(args: WorkerArgs): WorkerResult | null {
};
}

async function launchBrowserCandidate(
browser: BrowserCandidate,
profileDir: string,
): Promise<BrowserContext> {
return chromium.launchPersistentContext(profileDir, {
headless: false,
executablePath: browser.executablePath,
args: ["--disable-blink-features=AutomationControlled"],
viewport: { width: 1280, height: 900 },
});
}

async function run(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const fake = getFakeResult(args);
Expand All @@ -476,9 +495,9 @@ async function run(): Promise<void> {
}

mkdirSync(args.profileDir, { recursive: true });
const browsers = listInstalledChromiumAuthBrowsers();
const browsers = listAvailableChromiumAuthBrowsers();
if (browsers.length === 0) {
throw new Error(formatMissingChromiumAuthBrowserError());
browsers.push(await installManagedChromiumBrowser());
}

let context: BrowserContext | null = null;
Expand All @@ -487,12 +506,7 @@ async function run(): Promise<void> {
try {
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 },
});
context = await launchBrowserCandidate(candidate, args.profileDir);
browser = candidate;
break;
} catch (error) {
Expand All @@ -503,7 +517,18 @@ async function run(): Promise<void> {
}

if (!context || !browser) {
throw new Error(`Could not launch a supported browser. ${launchErrors.join(" ")}`);
const managedBrowser = await installManagedChromiumBrowser({ force: true });
try {
context = await launchBrowserCandidate(managedBrowser, args.profileDir);
browser = managedBrowser;
} catch (error) {
launchErrors.push(
`${managedBrowser.name}: ${error instanceof Error ? error.message : String(error)}`,
);
throw new Error(
`${formatMissingChromiumAuthBrowserError()} ${launchErrors.join(" ")}`.trim(),
);
}
}

let page = firstOpenPage(context) ?? (await context.newPage());
Expand Down
Loading