From ac5da901651a7df85f952d59e04095ce7d340011 Mon Sep 17 00:00:00 2001 From: suatsulun Date: Sun, 6 Sep 2026 22:30:26 +0300 Subject: [PATCH 1/6] fix(cli): open Chromium webview logins visibly --- packages/cli/src/commands/accounts/add.ts | 2 + packages/cli/src/lib/account-login.ts | 69 +++++++++++++++++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/accounts/add.ts b/packages/cli/src/commands/accounts/add.ts index 1db3c81b..9de51949 100644 --- a/packages/cli/src/commands/accounts/add.ts +++ b/packages/cli/src/commands/accounts/add.ts @@ -24,6 +24,7 @@ export default class AccountsAdd extends BeeperCommand { '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-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 executable for interactive webview login.' }), '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..eb475297 100644 --- a/packages/cli/src/lib/account-login.ts +++ b/packages/cli/src/lib/account-login.ts @@ -1,5 +1,8 @@ import { createInterface } from 'node:readline/promises' -import { execFileSync } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' +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 +16,7 @@ export type AccountLoginOptions = { nonInteractive?: boolean webview?: boolean webviewBackend?: 'auto' | 'chrome' | 'webkit' + webviewBrowserPath?: string webviewTimeoutMs?: number } @@ -173,12 +177,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 === 'chrome' || (!backend && process.platform !== 'darwin')) + ? options.webviewBrowserPath ?? 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 +205,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 +214,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 +245,48 @@ 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 { + return process.env.BUN_CHROME_PATH || [ + '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 process = 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 + process.once('error', error => { launchError = error }) + + try { + for (let attempt = 0; attempt < 100; attempt++) { + if (launchError) throw launchError + if (process.exitCode !== null) throw new Error(`Browser exited with code ${process.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 () => { + process.kill() + await Promise.race([new Promise(resolve => process.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) { + process.kill() + await rm(profile, { recursive: true, force: true }) + throw error } } From 4916a1232ca212465c0b8b4bc46c1349ed08c5f1 Mon Sep 17 00:00:00 2001 From: suatsulun Date: Wed, 9 Sep 2026 12:00:29 +0300 Subject: [PATCH 2/6] docs(cli): document webview browser path flag --- packages/cli/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/README.md b/packages/cli/README.md index f1167857..391a77ae 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1251,6 +1251,7 @@ Flags: | `--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-backend=` | option | Bun.WebView backend for cookie login steps. Default: chrome | +| `--webview-browser-path=` | option | Chromium-family browser executable for interactive webview login. | | `--webview-timeout=` | option | Seconds to wait for Bun.WebView cookie collection. Default: 120 | Examples: From 2765618d61abbf57932b0a9f88446177c400f4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 25 Sep 2026 15:42:28 +0000 Subject: [PATCH 3/6] refactor(cli): avoid shadowing process in browser launcher --- packages/cli/src/lib/account-login.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/lib/account-login.ts b/packages/cli/src/lib/account-login.ts index eb475297..7e6f629f 100644 --- a/packages/cli/src/lib/account-login.ts +++ b/packages/cli/src/lib/account-login.ts @@ -263,20 +263,20 @@ async function launchVisibleBrowser(path: string): Promise<{ url: string; close( const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response() }) const port = server.port await server.stop(true) - const process = spawn(path, [`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, '--no-first-run', '--no-default-browser-check', 'about:blank'], { stdio: 'ignore' }) + 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 - process.once('error', error => { launchError = error }) + browserProcess.once('error', error => { launchError = error }) try { for (let attempt = 0; attempt < 100; attempt++) { if (launchError) throw launchError - if (process.exitCode !== null) throw new Error(`Browser exited with code ${process.exitCode}.`) + 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 () => { - process.kill() - await Promise.race([new Promise(resolve => process.once('exit', () => resolve())), sleep(1000)]) + browserProcess.kill() + await Promise.race([new Promise(resolve => browserProcess.once('exit', () => resolve())), sleep(1000)]) await rm(profile, { recursive: true, force: true }) }, } @@ -284,7 +284,7 @@ async function launchVisibleBrowser(path: string): Promise<{ url: string; close( } throw new Error(`Timed out starting browser ${path}.`) } catch (error) { - process.kill() + browserProcess.kill() await rm(profile, { recursive: true, force: true }) throw error } From d8455bcf113a7bc16e57d4bc1810af146b29b7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 25 Sep 2026 15:42:28 +0000 Subject: [PATCH 4/6] fix(cli): honor --webview-browser-path with the auto backend on macOS --- packages/cli/src/lib/account-login.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/account-login.ts b/packages/cli/src/lib/account-login.ts index 7e6f629f..44e52f2c 100644 --- a/packages/cli/src/lib/account-login.ts +++ b/packages/cli/src/lib/account-login.ts @@ -183,9 +183,9 @@ async function collectCookieFieldsWithWebView(step: CookieLoginStep, options: Ac const backend = options.webviewBackend && options.webviewBackend !== 'auto' ? options.webviewBackend : undefined if (options.webviewBrowserPath && backend === 'webkit') throw new Error('--webview-browser-path requires the chrome backend.') - const browserPath = !options.nonInteractive && (backend === 'chrome' || (!backend && process.platform !== 'darwin')) - ? options.webviewBrowserPath ?? findChromiumBrowser(BunRuntime?.which) - : undefined + 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 From b015fb7ce4c437e85a2deb0115e67f05af126b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 25 Sep 2026 15:42:28 +0000 Subject: [PATCH 5/6] fix(cli): find Chromium browsers installed as macOS apps --- packages/cli/src/lib/account-login.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/account-login.ts b/packages/cli/src/lib/account-login.ts index 44e52f2c..4704cc88 100644 --- a/packages/cli/src/lib/account-login.ts +++ b/packages/cli/src/lib/account-login.ts @@ -1,5 +1,6 @@ import { createInterface } from 'node:readline/promises' 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' @@ -252,7 +253,13 @@ async function collectCookieFieldsWithWebView(step: CookieLoginStep, options: Ac } function findChromiumBrowser(which: ((name: string) => string | null) | undefined): string | undefined { - return process.env.BUN_CHROME_PATH || [ + 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)) From 602720ed7e3ee157c1fc08cf97847566f62516f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 25 Sep 2026 17:02:40 +0000 Subject: [PATCH 6/6] docs(cli): explain browser sign-in with --webview Document what opens during --webview sign-in (a visible browser with a fresh temporary profile), how the browser is found, what happens without one, and how each --webview-backend behaves. --- packages/cli/README.md | 4 +-- packages/cli/docs/accounts.md | 44 ++++++++++++++++++++--- packages/cli/src/commands/accounts/add.ts | 4 +-- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 391a77ae..320c8e8d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1249,9 +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 executable for interactive webview login. | +| `--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 9de51949..435e99eb 100644 --- a/packages/cli/src/commands/accounts/add.ts +++ b/packages/cli/src/commands/accounts/add.ts @@ -22,9 +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 executable for interactive webview login.' }), + '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.' }), }