Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1249,8 +1249,9 @@ Flags:
| `--guided` | boolean | Prompt through login steps until completion |
| `--login-id=<value>` | 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=<auto\|chrome\|webkit>` | option | Bun.WebView backend for cookie login steps. Default: chrome |
| `--webview-browser-path=<value>` | option | Chromium-family browser to open for --webview sign-in (Chrome, Brave, Edge, …). Defaults to the first one found. |
| `--webview-timeout=<value>` | option | Seconds to wait for Bun.WebView cookie collection. Default: 120 |

Examples:
Expand Down
44 changes: 39 additions & 5 deletions packages/cli/docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,9 @@ beeper accounts remove <selector>
- `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

Expand All @@ -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.
4 changes: 3 additions & 1 deletion packages/cli/src/commands/accounts/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' }),
}

Expand Down Expand Up @@ -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')
Expand Down
76 changes: 71 additions & 5 deletions packages/cli/src/lib/account-login.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -13,6 +17,7 @@ export type AccountLoginOptions = {
nonInteractive?: boolean
webview?: boolean
webviewBackend?: 'auto' | 'chrome' | 'webkit'
webviewBrowserPath?: string
webviewTimeoutMs?: number
}

Expand Down Expand Up @@ -173,12 +178,24 @@ export function setWebViewConstructorForTest(constructor: WebViewConstructor | u
}

async function collectCookieFieldsWithWebView(step: CookieLoginStep, options: AccountLoginOptions): Promise<Record<string, string>> {
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<WebViewConstructor>
try {
view = new WebView(browser ? { backend: { type: 'chrome', url: browser.url } } : backend ? { backend } : undefined)
} catch (error) {
await browser?.close()
throw error
}
const found: Record<string, string> = {}
const fields = normalizeCookieFields(step.fields)
const headerFields = fields.filter(field => field.sources.some(source => source.type === 'request_header'))
Expand All @@ -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)
Expand All @@ -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')
Expand Down Expand Up @@ -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<void> }> {
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<void>(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
}
}

Expand Down
Loading