diff --git a/README.md b/README.md index 9345645..c8b22c1 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Recording: record status Check recording status Tabs: - tab list / new [url] / close [n] + tab list / new [url] / close [id] Other: js Eval JS in page — strings print raw, objects print JSON @@ -95,7 +95,7 @@ Other: Flags: --timeout (default: 30000) - --tab target tab (default: 0) + --tab target tab by stable Chrome id (default: first page) --json structured output (snap, tab list, logs, audit) --right right-click --double double-click @@ -182,15 +182,25 @@ output. ### Tabs -`tab list` prints index, URL, and title for each open tab. -`tab new [url]` opens a new tab (optionally navigating) and prints its index. -`tab close [n]` closes a tab by index (default: last). Cannot close the last -remaining tab. +Every tab has a stable id (Chrome's CDP `targetId`) that survives other tabs +opening or closing. Use `--tab ` to target a specific tab from any +command. Without `--tab`, commands operate on the first open page. + +`tab list` prints id, URL, and title for each open tab. +`tab new [url]` opens a new tab (optionally navigating) and prints its id. +`tab close [id]` closes a tab by id (default: last opened). Cannot close the +last remaining tab. + +```bash +ID=$(mb tab new https://example.com) +mb --tab "$ID" shot example.png +mb tab close "$ID" +``` ### JSON output `--json` on `snap` returns `[{role, name, x, y, state}]`. -`--json` on `tab list` returns `[{index, url, title}]`. +`--json` on `tab list` returns `[{id, url, title}]`. `--json` on `logs` emits JSON lines `{tab, type, time, message}`. `--json` on `audit` returns the full audit data as a JSON object. diff --git a/package.json b/package.json index 22c21c2..128ce8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runablehq/mini-browser", - "version": "0.7.1", + "version": "1.0.0", "type": "module", "description": "A browser CLI for agents, built with Puppeteer", "keywords": [ diff --git a/src/commands/record.ts b/src/commands/record.ts index a48446f..8d46187 100644 --- a/src/commands/record.ts +++ b/src/commands/record.ts @@ -21,7 +21,7 @@ const getDaemonPath = () => { interface StartInput { path: string - tab: number + tab?: string fps: number scale: number } diff --git a/src/commands/tab.ts b/src/commands/tab.ts index 821396c..3f3c303 100644 --- a/src/commands/tab.ts +++ b/src/commands/tab.ts @@ -1,14 +1,15 @@ import puppeteer from "puppeteer-core" import { CDP_URL, VIEWPORT } from "../lib/config" +import { targetId } from "../lib/browser" import type { Flags } from "../lib/flags" const subcommands = { list, new: newTab, close } const help = `Usage: mb tab [args] - list List open tabs - new [url] Open new tab, print index - close [n] Close tab (default: last)` + list List open tabs (id, url, title) + new [url] Open new tab, print id + close [id] Close tab by id (default: last opened)` export const tab = async (args: string[], flags: Flags) => { const [sub, ...rest] = args @@ -24,14 +25,17 @@ export const tab = async (args: string[], flags: Flags) => { async function list(_args: string[], flags: Flags) { const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT }) - const pages = await browser.pages() + const targets = browser.targets().filter((t) => t.type() === "page") const entries = await Promise.all( - pages.map(async (page, i) => ({ - index: i, - url: page.url(), - title: await page.title(), - })) + targets.map(async (target) => { + const page = await target.page() + return { + id: targetId(target), + url: target.url(), + title: page ? await page.title() : "", + } + }), ) await browser.disconnect() @@ -41,15 +45,29 @@ async function list(_args: string[], flags: Flags) { return } - for (const { index, url, title } of entries) { - console.log(`${index}\t${url}\t${title}`) + for (const { id, url, title } of entries) { + console.log(`${id}\t${url}\t${title}`) } } async function newTab(args: string[], flags: Flags) { const url = args[0] const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT }) - const page = await browser.newPage() + + /* page.tagrte is deprecated in puppeteer-core, + so we use the CDP Target.createTarget directly to get the targetId */ + + const cdp = await browser.target().createCDPSession() + const { targetId: id } = await cdp.send("Target.createTarget", { url: "about:blank" }) + await cdp.detach() + + const target = await browser.waitForTarget((t) => targetId(t) === id) + const page = await target.page() + + if (!page) { + await browser.disconnect() + throw new Error("Failed to attach to new tab") + } if (url) { try { @@ -61,31 +79,35 @@ async function newTab(args: string[], flags: Flags) { } } - const pages = await browser.pages() - const index = pages.indexOf(page) await browser.disconnect() - console.log(index) + console.log(id) } async function close(args: string[], _flags: Flags) { const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT }) - const pages = await browser.pages() + const targets = browser.targets().filter((t) => t.type() === "page") - if (pages.length <= 1) { + if (targets.length <= 1) { await browser.disconnect() throw new Error("Cannot close the last tab") } - const index = args[0] !== undefined ? Number(args[0]) : pages.length - 1 + const requestedId = args[0] - if (!Number.isInteger(index) || index < 0 || index >= pages.length) { + const target = requestedId + ? targets.find((t) => targetId(t) === requestedId) + : targets[targets.length - 1] + + if (!target) { await browser.disconnect() - throw new Error(`Invalid tab index: ${args[0]}. Open tabs: 0-${pages.length - 1}`) + throw new Error(`No tab with id: ${requestedId}`) } - const target = pages[index]! + const id = targetId(target) const url = target.url() - await target.close() + const page = await target.page() + await page!.close() await browser.disconnect() - console.log(`Closed tab ${index}\t${url}`) + + console.log(`Closed tab ${id}\t${url}`) } diff --git a/src/index.ts b/src/index.ts index 22262c4..a77b617 100755 --- a/src/index.ts +++ b/src/index.ts @@ -60,12 +60,12 @@ Other: Tabs: tab list List open tabs - tab new [url] Open new tab, print index - tab close [n] Close tab (default: last) + tab new [url] Open new tab, print id + tab close [id] Close tab by id (default: last opened) Flags: --timeout Timeout (default: 30000) - --tab Tab index (default: 0) + --tab Stable Chrome target id (default: first page) --json JSON output --right/--double Right/double click --fps Recording frame rate (default: 30) diff --git a/src/lib/browser.ts b/src/lib/browser.ts index 7eaa179..828a238 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -1,33 +1,32 @@ -import puppeteer, { type Page } from "puppeteer-core" +import puppeteer, { type Target } from "puppeteer-core" import { CDP_URL, VIEWPORT } from "./config" -export const connect = async (tab = 0) => { +/* puppeteer-core does not expose a public method for the underlying CDP + target id, so we reach into `_targetId`. */ + +export const targetId = (t: Target) => + (t as unknown as { _targetId: string })._targetId + +export const connect = async (tab?: string) => { + const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT, }) - const pages = await browser.pages() - if (tab < 0 || tab >= pages.length) { + + const targets = browser.targets().filter((t) => t.type() === "page") + const target = tab ? targets.find((t) => targetId(t) === tab) : targets[0] + + const page = target ? await target.page() : null + + if (!page) { await browser.disconnect() - throw new Error(`Invalid tab index: ${tab}. Open tabs: 0-${pages.length - 1}`) + throw new Error(tab ? `No tab with id: ${tab}` : "No open tabs") } - const page = pages[tab]! - if (!page) throw new Error("No pages found") - return { browser, page, close: () => browser.disconnect() as Promise } -} -interface WithPageInput { - tab: number -} - -export const withPage = async ( - { tab }: WithPageInput, - fn: (page: Page) => Promise -) => { - const { page, close } = await connect(tab) - try { - return await fn(page) - } finally { - await close() + return { + browser, + page, + close: () => browser.disconnect() as Promise, } } diff --git a/src/lib/config.ts b/src/lib/config.ts index a115603..04d9005 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,4 +1,3 @@ export const CDP_URL = "http://127.0.0.1:9222" export const VIEWPORT = { width: 1024, height: 768 } export const DEFAULT_TIMEOUT = 30000 -export const DEFAULT_TAB = 0 diff --git a/src/lib/flags.ts b/src/lib/flags.ts index 7e9be85..bdc24b8 100644 --- a/src/lib/flags.ts +++ b/src/lib/flags.ts @@ -1,5 +1,5 @@ import { parseArgs } from "node:util" -import { DEFAULT_TAB, DEFAULT_TIMEOUT } from "./config" +import { DEFAULT_TIMEOUT } from "./config" interface NumericFlagInput { name: string @@ -38,7 +38,7 @@ export const parse = (argv: string[]) => { allowPositionals: true, options: { timeout: { type: "string", default: String(DEFAULT_TIMEOUT) }, - tab: { type: "string", default: String(DEFAULT_TAB) }, + tab: { type: "string" }, json: { type: "boolean", default: false }, right: { type: "boolean", default: false }, double: { type: "boolean", default: false }, @@ -58,7 +58,7 @@ export const parse = (argv: string[]) => { args: positionals, flags: { timeout: parseNumericFlag({ name: "timeout", value: values.timeout }), - tab: parseNumericFlag({ name: "tab", value: values.tab }), + tab: values.tab, json: values.json!, right: values.right!, double: values.double!, diff --git a/src/lib/recorder-state.ts b/src/lib/recorder-state.ts index 70c4bd6..e24ede6 100644 --- a/src/lib/recorder-state.ts +++ b/src/lib/recorder-state.ts @@ -7,7 +7,7 @@ const STATE_FILE = join(homedir(), ".mb-recorder.json") export interface RecorderState { pid: number path: string - tab: number + tab?: string fps: number scale: number startedAt: number diff --git a/src/recorder-daemon.ts b/src/recorder-daemon.ts index 4d20760..9fb5eae 100644 --- a/src/recorder-daemon.ts +++ b/src/recorder-daemon.ts @@ -8,7 +8,7 @@ import { clearState, writeState, readState } from "./lib/recorder-state" interface DaemonConfig { path: string - tab: number + tab?: string fps: number scale: number }