diff --git a/packages/cli/README.md b/packages/cli/README.md index f1167857..320c8e8d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1249,8 +1249,9 @@ Flags: | `--guided` | boolean | Prompt through login steps until completion | | `--login-id=` | option | Existing login ID to re-login as | | `--non-interactive` | boolean | Do not prompt; require --flow, --field, and --cookie values when needed. | -| `--webview` | boolean | Use Bun.WebView to collect cookie login fields when a cookie step is returned. | +| `--webview` | boolean | Sign in through a browser and collect cookie fields automatically. Interactive runs open a visible browser with a fresh temporary profile. | | `--webview-backend=` | option | Bun.WebView backend for cookie login steps. Default: chrome | +| `--webview-browser-path=` | option | Chromium-family browser to open for --webview sign-in (Chrome, Brave, Edge, …). Defaults to the first one found. | | `--webview-timeout=` | option | Seconds to wait for Bun.WebView cookie collection. Default: 120 | Examples: diff --git a/packages/cli/docs/accounts.md b/packages/cli/docs/accounts.md index 11610a42..337b12a8 100644 --- a/packages/cli/docs/accounts.md +++ b/packages/cli/docs/accounts.md @@ -29,10 +29,9 @@ beeper accounts remove - `accounts list --json` annotates the default account with `default: true`. - For non-interactive sign-in, pass `--flow`, `--field`, and `--cookie` and add `--non-interactive` to fail instead of prompting. -- For cookie-based sign-in, `--webview` can use Bun.WebView with Chrome to - collect cookie fields before falling back to prompts. Chrome remote debugging - must be enabled for a visible interactive tab; otherwise Bun may spawn a - headless browser. +- For cookie-based sign-in, `--webview` signs you in through a browser and + collects the cookie fields for you. Anything it can't collect falls back to + prompts. See [Browser sign-in](#browser-sign-in-webview). ## Examples @@ -41,9 +40,44 @@ beeper accounts list --json beeper bridges list beeper accounts add local-whatsapp beeper accounts add discord --non-interactive --cookie sessionid=… -beeper accounts add discord --webview --webview-backend chrome +beeper accounts add discord --webview +beeper accounts add discord --webview --webview-browser-path /usr/bin/brave-browser beeper accounts use whatsapp-main beeper accounts use "" beeper accounts show whatsapp-main --json beeper accounts remove whatsapp-main ``` + +## Browser sign-in (`--webview`) + +Some networks (Discord, Instagram, LinkedIn, …) sign in with cookies. With +`--webview`, the CLI opens the network's login page, you sign in, and the CLI +reads the cookies it needs. Requires Bun (`Bun.WebView`). + +What opens: + +- **A visible browser window, in a fresh temporary profile.** When run + interactively, the CLI starts a Chromium-family browser with an empty profile + and a DevTools port on `127.0.0.1`. You won't be signed in to anything there, + and your normal browser profile, cookies, and extensions are never touched. + The window and its profile are deleted when sign-in finishes, fails, or times + out. +- **Browser lookup order:** `--webview-browser-path`, then `BUN_CHROME_PATH`, + then the first installed of Chrome, Chromium, Brave, Edge, Opera, and Vivaldi + (on PATH on Linux; in `/Applications` on macOS). +- **No browser found**, or `--non-interactive`: the CLI falls back to Bun's + built-in browser. With `--webview-backend chrome` Bun attaches to a Chrome + that has remote debugging enabled, or starts a headless one. Headless only + works for pages that finish without typing, so you'll usually get the manual + cookie prompts instead. + +Backends: + +| `--webview-backend` | Interactive | `--non-interactive` | +| --- | --- | --- | +| `chrome` (default) | Visible browser, fresh profile | Bun's Chrome (attached or headless) | +| `auto` | Linux: visible browser. macOS: WebKit, unless `--webview-browser-path` is set | Bun default | +| `webkit` | macOS WebKit (headless) | Same | + +`--webview-browser-path` can't be combined with `webkit`. `--webview-timeout` +(default 120 seconds) limits how long the CLI waits for the cookies. diff --git a/packages/cli/src/commands/accounts/add.ts b/packages/cli/src/commands/accounts/add.ts index 1db3c81b..435e99eb 100644 --- a/packages/cli/src/commands/accounts/add.ts +++ b/packages/cli/src/commands/accounts/add.ts @@ -22,8 +22,9 @@ export default class AccountsAdd extends BeeperCommand { guided: Flags.boolean({ default: true, allowNo: true, description: 'Prompt through login steps until completion' }), 'login-id': Flags.string({ description: 'Existing login ID to re-login as' }), 'non-interactive': Flags.boolean({ default: false, description: 'Do not prompt; require --flow, --field, and --cookie values when needed.' }), - webview: Flags.boolean({ default: false, description: 'Use Bun.WebView to collect cookie login fields when a cookie step is returned.' }), + webview: Flags.boolean({ default: false, description: 'Sign in through a browser and collect cookie fields automatically. Interactive runs open a visible browser with a fresh temporary profile.' }), 'webview-backend': Flags.string({ default: 'chrome', description: 'Bun.WebView backend for cookie login steps.', options: ['auto', 'chrome', 'webkit'] }), + 'webview-browser-path': Flags.string({ description: 'Chromium-family browser to open for --webview sign-in (Chrome, Brave, Edge, …). Defaults to the first one found.' }), 'webview-timeout': Flags.integer({ default: 120, description: 'Seconds to wait for Bun.WebView cookie collection.' }), } @@ -77,6 +78,7 @@ export default class AccountsAdd extends BeeperCommand { nonInteractive: flags['non-interactive'], webview: flags.webview, webviewBackend: flags['webview-backend'] as 'auto' | 'chrome' | 'webkit', + webviewBrowserPath: flags['webview-browser-path'], webviewTimeoutMs: flags['webview-timeout'] * 1000, }) : step if (flags.json) await printData(result, 'json') diff --git a/packages/cli/src/lib/account-login.ts b/packages/cli/src/lib/account-login.ts index 1572d2ff..4704cc88 100644 --- a/packages/cli/src/lib/account-login.ts +++ b/packages/cli/src/lib/account-login.ts @@ -1,5 +1,9 @@ import { createInterface } from 'node:readline/promises' -import { execFileSync } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { stdin as input, stderr as output } from 'node:process' import QRCode from 'qrcode' import type { LoginSession } from '@beeper/desktop-api/resources/bridges.js' @@ -13,6 +17,7 @@ export type AccountLoginOptions = { nonInteractive?: boolean webview?: boolean webviewBackend?: 'auto' | 'chrome' | 'webkit' + webviewBrowserPath?: string webviewTimeoutMs?: number } @@ -173,12 +178,24 @@ export function setWebViewConstructorForTest(constructor: WebViewConstructor | u } async function collectCookieFieldsWithWebView(step: CookieLoginStep, options: AccountLoginOptions): Promise> { - const BunRuntime = (globalThis as { Bun?: { WebView?: WebViewConstructor } }).Bun + const BunRuntime = (globalThis as { Bun?: { WebView?: WebViewConstructor; which?(name: string): string | null } }).Bun const WebView = webViewConstructorOverride ?? BunRuntime?.WebView if (!WebView) throw new Error('Bun.WebView is not available in this Bun runtime.') const backend = options.webviewBackend && options.webviewBackend !== 'auto' ? options.webviewBackend : undefined - const view = new WebView(backend ? { backend } : undefined) + if (options.webviewBrowserPath && backend === 'webkit') throw new Error('--webview-browser-path requires the chrome backend.') + const browserPath = options.nonInteractive || backend === 'webkit' + ? undefined + : options.webviewBrowserPath ?? (backend === 'chrome' || process.platform !== 'darwin' ? findChromiumBrowser(BunRuntime?.which) : undefined) + const browser = browserPath ? await launchVisibleBrowser(browserPath) : undefined + const usesChrome = backend === 'chrome' || Boolean(browser) + let view: InstanceType + try { + view = new WebView(browser ? { backend: { type: 'chrome', url: browser.url } } : backend ? { backend } : undefined) + } catch (error) { + await browser?.close() + throw error + } const found: Record = {} const fields = normalizeCookieFields(step.fields) const headerFields = fields.filter(field => field.sources.some(source => source.type === 'request_header')) @@ -189,7 +206,7 @@ async function collectCookieFieldsWithWebView(step: CookieLoginStep, options: Ac } try { - if ((step.userAgent || headerFields.length > 0 || fields.some(field => field.sources.some(source => source.type === 'cookie' && source.cookieDomain))) && backend === 'chrome' && view.cdp) { + if ((step.userAgent || headerFields.length > 0 || fields.some(field => field.sources.some(source => source.type === 'cookie' && source.cookieDomain))) && usesChrome && view.cdp) { await view.navigate('about:blank') if (step.userAgent) await view.cdp('Emulation.setUserAgentOverride', { userAgent: step.userAgent }) await setupChromeNetworkCapture(view, headerFields, found) @@ -198,7 +215,9 @@ async function collectCookieFieldsWithWebView(step: CookieLoginStep, options: Ac } output.write(`webview: opening ${step.url}\n`) - if (backend === 'chrome') { + if (browser) { + output.write('webview: complete sign-in in the opened browser window.\n') + } else if (backend === 'chrome') { output.write('webview: complete sign-in in the opened Chrome tab. If no tab appears, enable Chrome remote debugging and retry.\n') } else { output.write('webview: running in headless mode; cookie fields will be collected if the page can complete without manual input.\n') @@ -227,7 +246,54 @@ async function collectCookieFieldsWithWebView(step: CookieLoginStep, options: Ac const missing = fields.filter(field => field.required && found[field.id] === undefined).map(field => field.id) throw new Error(`Timed out waiting for cookie fields${missing.length ? `: ${missing.join(', ')}` : ''}.`) } finally { + if (browser && view.cdp) await view.cdp('Browser.close').catch(() => undefined) view.close() + await browser?.close() + } +} + +function findChromiumBrowser(which: ((name: string) => string | null) | undefined): string | undefined { + if (process.env.BUN_CHROME_PATH) return process.env.BUN_CHROME_PATH + if (process.platform === 'darwin') { + return ['Google Chrome', 'Chromium', 'Brave Browser', 'Microsoft Edge', 'Vivaldi', 'Opera'] + .map(name => `/Applications/${name}.app/Contents/MacOS/${name}`) + .find(path => existsSync(path)) + } + return [ + 'google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser', + 'brave-browser', 'microsoft-edge', 'opera-gx', 'opera', 'vivaldi', + ].map(name => which?.(name)).find((path): path is string => Boolean(path)) +} + +async function launchVisibleBrowser(path: string): Promise<{ url: string; close(): Promise }> { + const profile = await mkdtemp(join(tmpdir(), 'beeper-webview-')) + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response() }) + const port = server.port + await server.stop(true) + const browserProcess = spawn(path, [`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, '--no-first-run', '--no-default-browser-check', 'about:blank'], { stdio: 'ignore' }) + let launchError: Error | undefined + browserProcess.once('error', error => { launchError = error }) + + try { + for (let attempt = 0; attempt < 100; attempt++) { + if (launchError) throw launchError + if (browserProcess.exitCode !== null) throw new Error(`Browser exited with code ${browserProcess.exitCode}.`) + const endpoint = await fetch(`http://127.0.0.1:${port}/json/version`).then(response => response.json() as Promise<{ webSocketDebuggerUrl?: string }>).catch(() => undefined) + if (endpoint?.webSocketDebuggerUrl) return { + url: endpoint.webSocketDebuggerUrl, + close: async () => { + browserProcess.kill() + await Promise.race([new Promise(resolve => browserProcess.once('exit', () => resolve())), sleep(1000)]) + await rm(profile, { recursive: true, force: true }) + }, + } + await sleep(100) + } + throw new Error(`Timed out starting browser ${path}.`) + } catch (error) { + browserProcess.kill() + await rm(profile, { recursive: true, force: true }) + throw error } }