diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index 92e314e..a2447a4 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -122,6 +122,45 @@ class BrowserCLI: """Navigate a specific tab to *url*.""" self._cmd("navigate.to", {"tabId": tab_id, "url": url}) + def open_wait( + self, + url: str, + *, + timeout: float = 30.0, + background: bool = False, + window: str | None = None, + group: str | None = None, + ) -> "Tab": + """Open URL in a new tab and block until fully loaded. Returns the Tab.""" + data = self._cmd("navigate.open_wait", { + "url": url, "timeout": int(timeout * 1000), + "background": background, "window": window, "group": group, + }) + return self._make_tab(data) if isinstance(data, dict) and "id" in data else data + + def wait_for_load( + self, + tab_id: int | None = None, + *, + timeout: float = 30.0, + ready_state: str = "complete", + ) -> Tab: + """Block until the tab finishes loading. Returns the Tab when ready. + + Args: + tab_id: Tab to watch. Defaults to the active tab. + timeout: Max seconds to wait before raising ``RuntimeError``. + ready_state: ``"complete"`` (default) or ``"interactive"``. + """ + data = self._cmd("navigate.wait", { + "tabId": tab_id, + "timeout": int(timeout * 1000), + "readyState": ready_state, + }) + if not isinstance(data, dict) or "id" not in data: + raise RuntimeError("navigate.wait returned unexpected data") + return self._make_tab(data) + # ── Search ──────────────────────────────────────────────────────────── def search( @@ -190,6 +229,44 @@ class BrowserCLI: result = self._cmd("tabs.unmute", {"tabId": tab_id}) return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0) + def tabs_pin(self, tab_id: int | None = None) -> int: + """Pin the active tab or a specific tab. Returns the target tab ID.""" + result = self._cmd("tabs.pin", {"tabId": tab_id}) + return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0) + + def tabs_unpin(self, tab_id: int | None = None) -> int: + """Unpin the active tab or a specific tab. Returns the target tab ID.""" + result = self._cmd("tabs.unpin", {"tabId": tab_id}) + return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0) + + def tabs_watch_url( + self, + pattern: str, + *, + tab_id: int | None = None, + timeout: float = 30.0, + ) -> "Tab": + """Block until the tab URL matches regex pattern. Returns the Tab.""" + data = self._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)}) + return self._make_tab(data) if isinstance(data, dict) and "id" in data else data + + def tabs_screenshot( + self, + tab_id: int | None = None, + *, + format: str = "png", + quality: int | None = None, + ) -> str: + """Capture the visible area of a tab. Returns a base64 data URL. + + Args: + tab_id: Tab to capture. Defaults to the active tab. + format: ``"png"`` (default) or ``"jpeg"``. + quality: JPEG quality 0-100 (ignored for PNG). + """ + result = self._cmd("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality}) + return result.get("dataUrl", "") if isinstance(result, dict) else str(result) + def window_active_tab(self, window_id: int) -> Tab: """Return active tab for a specific browser window.""" data = self._cmd("tabs.active_in_window", {"windowId": window_id}) @@ -346,6 +423,161 @@ class BrowserCLI: def dom_exists(self, selector: str) -> bool: return self._cmd("dom.exists", {"selector": selector}) + def dom_scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None: + """Scroll to a CSS selector or to pixel coordinates.""" + self._cmd("dom.scroll", {"selector": selector, "x": x, "y": y}) + + def dom_select(self, selector: str, value: str) -> None: + """Set the value of a dropdown matching CSS SELECTOR.""" + _handle("dom.select", {"selector": selector, "value": value}) + console.print(f"[green]Selected '{value}' in:[/green] {selector}") + + +@dom_group.command("eval") +@click.argument("code") +@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)") +def dom_eval(code, tab_id): + """Evaluate JavaScript CODE in the page and print the result.""" + result = _handle("dom.eval", {"code": code, "tabId": tab_id}) + if result is None: + console.print("[dim]null[/dim]") + else: + console.print(json.dumps(result, indent=2) if isinstance(result, (dict, list)) else str(result)) + + +@dom_group.command("wait-for") +@click.argument("selector") +@click.option("--timeout", type=float, default=10.0, show_default=True, help="Max seconds to wait") +@click.option("--visible", is_flag=True, help="Wait until element is visible (non-zero size)") +@click.option("--hidden", is_flag=True, help="Wait until element is absent or hidden") +@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)") +def dom_wait_for(selector, timeout, visible, hidden, tab_id): + """Wait until CSS SELECTOR appears (or disappears) in the DOM.""" + _handle("dom.wait_for", { + "selector": selector, + "timeout": int(timeout * 1000), + "visible": visible, + "hidden": hidden, + "tabId": tab_id, + }) + state = "hidden" if hidden else ("visible" if visible else "present") + console.print(f"[green]Ready ({state}):[/green] {selector}") + + +@dom_group.command("key") +@click.argument("key") +@click.option("--selector", default=None, help="CSS selector to target (default: focused element)") +def dom_key(key, selector): + """Dispatch a keyboard KEY event (e.g. Enter, Tab, Escape, ArrowDown).""" + _handle("dom.key", {"key": key, "selector": selector}) + target = selector or "active element" + console.print(f"[green]Key '{key}' sent to:[/green] {target}") + + +@dom_group.command("hover") +@click.argument("selector") +def dom_hover(selector): + """Dispatch mouseover/mouseenter on the element matching CSS SELECTOR.""" + _handle("dom.hover", {"selector": selector}) + console.print(f"[green]Hovered:[/green] {selector}") + + +@dom_group.command("check") +@click.argument("selector") +def dom_check(selector): + """Check a checkbox matching CSS SELECTOR.""" + _handle("dom.check", {"selector": selector}) + console.print(f"[green]Checked:[/green] {selector}") + + +@dom_group.command("uncheck") +@click.argument("selector") +def dom_uncheck(selector): + """Uncheck a checkbox matching CSS SELECTOR.""" + _handle("dom.uncheck", {"selector": selector}) + console.print(f"[green]Unchecked:[/green] {selector}") + + +@dom_group.command("clear") +@click.argument("selector") +def dom_clear(selector): + """Clear the value of an input matching CSS SELECTOR.""" + _handle("dom.clear", {"selector": selector}) + console.print(f"[green]Cleared:[/green] {selector}") + + +@dom_group.command("focus") +@click.argument("selector") +def dom_focus(selector): + """Focus the element matching CSS SELECTOR.""" + _handle("dom.focus", {"selector": selector}) + console.print(f"[green]Focused:[/green] {selector}") + + +@dom_group.command("submit") +@click.argument("selector") +def dom_submit(selector): + """Submit the form that contains the element matching CSS SELECTOR.""" + _handle("dom.submit", {"selector": selector}) + console.print(f"[green]Submitted form for:[/green] {selector}") + + +@dom_group.command("poll") +@click.argument("selector") +@click.argument("pattern") +@click.option("--attr", default=None, help="Attribute or property to read (default: textContent/value)") +@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait") +@click.option("--interval", type=float, default=0.5, show_default=True, help="Poll interval in seconds") +@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)") +def dom_poll(selector, pattern, attr, timeout, interval, tab_id): + """Poll SELECTOR until its text/value matches regex PATTERN.""" + result = _handle("dom.poll", { + "selector": selector, + "pattern": pattern, + "attr": attr, + "timeout": int(timeout * 1000), + "interval": int(interval * 1000), + "tabId": tab_id, + }) + value = result.get("value", "") if isinstance(result, dict) else "" + console.print(f"[green]Matched:[/green] {selector!r} = {value!r}") diff --git a/browser_cli/commands/navigate.py b/browser_cli/commands/navigate.py index d1e4ab3..70bcbed 100644 --- a/browser_cli/commands/navigate.py +++ b/browser_cli/commands/navigate.py @@ -78,3 +78,29 @@ def cmd_focus(pattern): console.print(f"[green]Focused:[/green] {result.get('url', result)}") else: console.print(f"[yellow]No tab found matching:[/yellow] {pattern}") + + +@nav_group.command("open-wait") +@click.argument("url") +@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load") +@click.option("--bg", is_flag=True, help="Open in background (no focus)") +@click.option("--window", "window_name", default=None, help="Open in named window") +@click.option("--group", "group_name", default=None, help="Open in tab group") +def cmd_open_wait(url, timeout, bg, window_name, group_name): + """Open URL in a new tab and wait until fully loaded.""" + result = _handle("navigate.open_wait", { + "url": url, "timeout": int(timeout * 1000), + "background": bg, "window": window_name, "group": group_name, + }) + title = result.get("title", "") if isinstance(result, dict) else "" + console.print(f"[green]Loaded:[/green] {url}" + (f" — {title}" if title else "")) + + +@nav_group.command("wait") +@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)") +@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait") +@click.option("--ready-state", type=click.Choice(["complete", "interactive"]), default="complete", show_default=True, help="Target ready state") +def cmd_wait(tab_id, timeout, ready_state): + """Wait until tab finishes loading.""" + result = _handle("navigate.wait", {"tabId": tab_id, "timeout": int(timeout * 1000), "readyState": ready_state}) + console.print(f"[green]Ready:[/green] {result.get('url', '')} — {result.get('title', '')}") diff --git a/browser_cli/commands/page.py b/browser_cli/commands/page.py new file mode 100644 index 0000000..2579cc4 --- /dev/null +++ b/browser_cli/commands/page.py @@ -0,0 +1,38 @@ +import click +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console +from rich.table import Table + +console = Console() + + +def _handle(command, args=None): + try: + return send_command(command, args or {}) + except BrowserNotConnected as e: + console.print(f"[red]Error:[/red] {e}") + raise SystemExit(1) + except RuntimeError as e: + console.print(f"[red]Browser error:[/red] {e}") + raise SystemExit(1) + + +@click.group("page") +def page_group(): + """Inspect current page metadata.""" + + +@page_group.command("info") +def page_info(): + """Show title, URL, readyState, language, and meta tags of the active tab.""" + info = _handle("page.info") or {} + table = Table(show_header=False) + table.add_column("Field", style="bold cyan", no_wrap=True) + table.add_column("Value") + table.add_row("Title", info.get("title") or "") + table.add_row("URL", info.get("url") or "") + table.add_row("Ready", info.get("readyState") or "") + table.add_row("Lang", info.get("lang") or "") + for key, val in (info.get("meta") or {}).items(): + table.add_row(f"meta:{key}", val) + console.print(table) diff --git a/browser_cli/commands/storage.py b/browser_cli/commands/storage.py new file mode 100644 index 0000000..dc576de --- /dev/null +++ b/browser_cli/commands/storage.py @@ -0,0 +1,48 @@ +import json +import click +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console + +console = Console() + + +def _handle(command, args=None): + try: + return send_command(command, args or {}) + except BrowserNotConnected as e: + console.print(f"[red]Error:[/red] {e}") + raise SystemExit(1) + except RuntimeError as e: + console.print(f"[red]Browser error:[/red] {e}") + raise SystemExit(1) + + +@click.group("storage") +def storage_group(): + """Read and write the page's localStorage / sessionStorage.""" + + +@storage_group.command("get") +@click.argument("key", required=False) +@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True) +@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)") +def storage_get(key, store_type, tab_id): + """Get a localStorage KEY (or dump all keys if omitted).""" + result = _handle("storage.get", {"key": key, "type": store_type, "tabId": tab_id}) + if result is None: + console.print("[dim]null[/dim]") + elif isinstance(result, dict): + console.print(json.dumps(result, indent=2)) + else: + console.print(str(result)) + + +@storage_group.command("set") +@click.argument("key") +@click.argument("value") +@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True) +@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)") +def storage_set(key, value, store_type, tab_id): + """Set localStorage KEY to VALUE.""" + _handle("storage.set", {"key": key, "value": value, "type": store_type, "tabId": tab_id}) + console.print(f"[green]Set[/green] {store_type}[{key!r}] = {value!r}") diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py index 0acd4d4..b640f5c 100644 --- a/browser_cli/commands/tabs.py +++ b/browser_cli/commands/tabs.py @@ -1,3 +1,4 @@ +import base64 import click from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command from rich.console import Console @@ -236,3 +237,54 @@ def tabs_unmute(tab_id): result = _handle("tabs.unmute", {"tabId": tab_id}) target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id console.print(f"[green]Unmuted tab {target}[/green]") + + +@tabs_group.command("pin") +@click.argument("tab_id", type=int, required=False) +def tabs_pin(tab_id): + """Pin the active tab or a specific tab.""" + result = _handle("tabs.pin", {"tabId": tab_id}) + target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id + console.print(f"[green]Pinned tab {target}[/green]") + + +@tabs_group.command("unpin") +@click.argument("tab_id", type=int, required=False) +def tabs_unpin(tab_id): + """Unpin the active tab or a specific tab.""" + result = _handle("tabs.unpin", {"tabId": tab_id}) + target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id + console.print(f"[green]Unpinned tab {target}[/green]") + + +@tabs_group.command("watch-url") +@click.argument("pattern") +@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)") +@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait") +def tabs_watch_url(pattern, tab_id, timeout): + """Wait until the active (or specified) tab URL matches regex PATTERN.""" + result = _handle("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)}) + url = result.get("url", "") if isinstance(result, dict) else "" + console.print(f"[green]URL matched:[/green] {url}") + + +@tabs_group.command("screenshot") +@click.argument("output", required=False, metavar="FILE") +@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)") +@click.option("--format", "fmt", type=click.Choice(["png", "jpeg"]), default="png", show_default=True) +@click.option("--quality", type=int, default=None, help="JPEG quality 0-100") +def tabs_screenshot(output, tab_id, fmt, quality): + """Capture a screenshot of the active (or specified) tab. + + Saves to FILE if given, otherwise prints the base64 data URL. + """ + result = _handle("tabs.screenshot", {"tabId": tab_id, "format": fmt, "quality": quality}) + data_url = result.get("dataUrl", "") if isinstance(result, dict) else "" + if output: + header = f"data:image/{fmt};base64," + raw = base64.b64decode(data_url[len(header):]) + with open(output, "wb") as f: + f.write(raw) + console.print(f"[green]Screenshot saved:[/green] {output}") + else: + console.print(data_url) diff --git a/extension/background.js b/extension/background.js index b0026f2..988496a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -127,6 +127,8 @@ async function dispatch(command, args) { case "navigate.back": return navBack(args); case "navigate.forward": return navForward(args); case "navigate.focus": return navFocus(args); + case "navigate.wait": return navWait(args); + case "navigate.open_wait": return navOpenWait(args); // ── Tabs ────────────────────────────────────────────────────────────── case "tabs.list": return tabsList(); @@ -144,6 +146,10 @@ async function dispatch(command, args) { case "tabs.merge_windows": return tabsMergeWindows(); case "tabs.mute": return tabsMute(args); case "tabs.unmute": return tabsUnmute(args); + case "tabs.pin": return tabsPin(args); + case "tabs.unpin": return tabsUnpin(args); + case "tabs.screenshot": return tabsScreenshot(args); + case "tabs.watch_url": return tabsWatchUrl(args); // ── Groups ──────────────────────────────────────────────────────────── case "group.list": return groupList(); @@ -162,12 +168,36 @@ async function dispatch(command, args) { case "windows.open": return windowsOpen(args); // ── DOM ─────────────────────────────────────────────────────────────── - case "dom.query": return domOp("domQuery", args); - case "dom.click": return domOp("domClick", args); - case "dom.type": return domOp("domType", args); - case "dom.attr": return domOp("domAttr", args); - case "dom.text": return domOp("domText", args); - case "dom.exists": return domOp("domExists", args); + case "dom.query": return domOp("domQuery", args); + case "dom.click": return domOp("domClick", args); + case "dom.type": return domOp("domType", args); + case "dom.attr": return domOp("domAttr", args); + case "dom.text": return domOp("domText", args); + case "dom.exists": return domOp("domExists", args); + case "dom.scroll": return domOp("domScroll", args); + case "dom.select": return domOp("domSelect", args); + case "dom.key": return domOp("domKey", args); + case "dom.hover": return domOp("domHover", args); + case "dom.check": return domOp("domCheck", { ...args, checked: true }); + case "dom.uncheck": return domOp("domCheck", { ...args, checked: false }); + case "dom.clear": return domOp("domClear", args); + case "dom.focus": return domOp("domFocus", args); + case "dom.submit": return domOp("domSubmit", args); + case "dom.eval": return domEval(args); + case "dom.wait_for": return domWaitFor(args); + case "dom.poll": return domPoll(args); + + // ── Page ────────────────────────────────────────────────────────────── + case "page.info": return domOp("pageInfo", {}); + + // ── Storage ─────────────────────────────────────────────────────────── + case "storage.get": return storageGet(args); + case "storage.set": return storageSet(args); + + // ── Cookies ─────────────────────────────────────────────────────────── + case "cookies.list": return cookiesList(args); + case "cookies.get": return cookiesGet(args); + case "cookies.set": return cookiesSet(args); // ── Extract ─────────────────────────────────────────────────────────── case "extract.links": return domOp("extractLinks", args); @@ -267,6 +297,25 @@ async function navFocus({ pattern }) { return { id: match.id, url: match.url || match.pendingUrl, title: match.title }; } +async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + const deadline = Date.now() + timeout; + const interval = 200; + while (Date.now() < deadline) { + const t = await chrome.tabs.get(tab.id); + if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") { + return tabInfo(t); + } + await new Promise(r => setTimeout(r, interval)); + } + throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`); +} + +async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) { + const opened = await navOpen({ url, background, window: windowName, group }); + return await navWait({ tabId: opened.id, timeout }); +} + // ── Tabs ────────────────────────────────────────────────────────────────────── async function tabsList() { @@ -442,6 +491,47 @@ async function tabsMergeWindows() { return { moved }; } +async function tabsPin({ tabId }) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + await chrome.tabs.update(tab.id, { pinned: true }); + return { tabId: tab.id, pinned: true }; +} + +async function tabsUnpin({ tabId }) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + await chrome.tabs.update(tab.id, { pinned: false }); + return { tabId: tab.id, pinned: false }; +} + +async function tabsScreenshot({ tabId, format = "png", quality } = {}) { + let windowId; + if (tabId) { + const tab = await chrome.tabs.get(tabId); + await chrome.tabs.update(tabId, { active: true }); + windowId = tab.windowId; + } else { + const tab = await getActiveTab(); + windowId = tab.windowId; + } + const opts = { format }; + if (format === "jpeg" && quality != null) opts.quality = quality; + const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts); + return { dataUrl, format }; +} + +async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + const deadline = Date.now() + timeout; + const regex = new RegExp(pattern); + while (Date.now() < deadline) { + const t = await chrome.tabs.get(tab.id); + const url = t.url || t.pendingUrl || ""; + if (regex.test(url)) return tabInfo(t); + await new Promise(r => setTimeout(r, 200)); + } + throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`); +} + async function tabsMute({ tabId }) { const tab = await resolveTabForDirectAction(tabId, "mute"); await chrome.tabs.update(tab.id, { muted: true }); @@ -616,6 +706,131 @@ async function domOp(funcName, args) { return results[0]?.result; } +async function domEval({ code, tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); + } + const results = await executeScript({ + target: { tabId: tab.id }, + world: "MAIN", + func: (c) => (0, eval)(c), + args: [code], + }); + return results[0]?.result ?? null; +} + +async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); + } + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const results = await executeScript({ + target: { tabId: tab.id }, + func: (sel, vis, hid) => { + const el = document.querySelector(sel); + if (hid) return !el || el.offsetParent === null; + if (!el) return false; + if (vis) { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + } + return true; + }, + args: [selector, visible, hidden], + }); + if (results[0]?.result) return { selector, found: !hidden }; + await new Promise(r => setTimeout(r, 200)); + } + throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`); +} + +async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); + } + const deadline = Date.now() + timeout; + const regex = new RegExp(pattern); + while (Date.now() < deadline) { + const results = await executeScript({ + target: { tabId: tab.id }, + func: (sel, a) => { + const el = document.querySelector(sel); + if (!el) return null; + if (a) return el.getAttribute(a) ?? el[a] ?? null; + return el.value !== undefined ? el.value : el.textContent.trim(); + }, + args: [selector, attr || null], + }); + const value = results[0]?.result; + if (value != null && regex.test(String(value))) return { selector, value, pattern }; + await new Promise(r => setTimeout(r, interval)); + } + throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`); +} + +async function storageGet({ key, type = "local", tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); + } + const results = await executeScript({ + target: { tabId: tab.id }, + world: "MAIN", + func: (k, t) => { + const store = t === "session" ? sessionStorage : localStorage; + if (k) return store.getItem(k); + return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)])); + }, + args: [key || null, type], + }); + return results[0]?.result ?? null; +} + +async function storageSet({ key, value, type = "local", tabId } = {}) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`); + } + const results = await executeScript({ + target: { tabId: tab.id }, + world: "MAIN", + func: (k, v, t) => { + const store = t === "session" ? sessionStorage : localStorage; + store.setItem(k, typeof v === "string" ? v : JSON.stringify(v)); + return true; + }, + args: [key, value, type], + }); + return results[0]?.result ?? false; +} + +async function cookiesList({ url, domain, name } = {}) { + const details = {}; + if (url) details.url = url; + if (domain) details.domain = domain; + if (name) details.name = name; + return await chrome.cookies.getAll(details); +} + +async function cookiesGet({ url, name }) { + return await chrome.cookies.get({ url, name }); +} + +async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) { + const details = { url, name, value }; + if (domain != null) details.domain = domain; + if (path != null) details.path = path; + if (secure != null) details.secure = secure; + if (httpOnly != null) details.httpOnly = httpOnly; + if (expirationDate != null) details.expirationDate = expirationDate; + if (sameSite != null) details.sameSite = sameSite; + return await chrome.cookies.set(details); +} + // This function is serialized and injected into the page by chrome.scripting function contentDispatch(funcName, args) { function domQuery({ selector }) { @@ -653,6 +868,83 @@ function contentDispatch(funcName, args) { function domExists({ selector }) { return document.querySelector(selector) !== null; } + function domKey({ selector, key }) { + const el = selector ? document.querySelector(selector) : document.activeElement; + if (selector && !el) throw new Error(`No element: ${selector}`); + const target = el || document.body; + ["keydown", "keypress", "keyup"].forEach(type => { + target.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true })); + }); + return true; + } + function domHover({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + return true; + } + function domCheck({ selector, checked }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.checked = checked; + el.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } + function domClear({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.value = ""; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } + function domFocus({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.focus(); + return true; + } + function domSubmit({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + const form = el.tagName === "FORM" ? el : el.closest("form"); + if (!form) throw new Error(`No form found for: ${selector}`); + form.submit(); + return true; + } + function pageInfo() { + const metas = {}; + document.querySelectorAll("meta[name], meta[property]").forEach(m => { + const k = m.getAttribute("name") || m.getAttribute("property"); + if (k) metas[k] = m.getAttribute("content") || ""; + }); + return { + title: document.title, + url: location.href, + readyState: document.readyState, + lang: document.documentElement.lang || null, + meta: metas, + }; + } + function domScroll({ selector, x, y }) { + if (selector) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.scrollIntoView({ behavior: "smooth", block: "center" }); + return true; + } + window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" }); + return true; + } + function domSelect({ selector, value }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + el.dispatchEvent(new Event("input", { bubbles: true })); + return true; + } function extractLinks() { const seen = new Set(); return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => { @@ -1090,6 +1382,8 @@ function contentDispatch(funcName, args) { } const fns = { domQuery, domClick, domType, domAttr, domText, domExists, + domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit, + pageInfo, extractLinks, extractImages, extractText, extractJson, extractMarkdown }; const fn = fns[funcName]; if (!fn) throw new Error(`Unknown content function: ${funcName}`); diff --git a/extension/manifest.json b/extension/manifest.json index afe6445..c8762f0 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.5.12", + "version": "0.6.0", "description": "Control your browser from the terminal via browser-cli", "permissions": [ "tabs", @@ -10,7 +10,8 @@ "windows", "storage", "alarms", - "nativeMessaging" + "nativeMessaging", + "cookies" ], "host_permissions": [""], "background": { diff --git a/pyproject.toml b/pyproject.toml index 44201bd..48f3008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.5.12" +version = "0.6.0" description = "Control your real running browser from the terminal via a browser extension" requires-python = ">=3.10" dependencies = [