Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
479a0f1964
|
|||
|
371b794170
|
|||
|
0ac652beee
|
|||
|
7cb2a8b618
|
|||
|
0b43408a8d
|
|||
|
657b1b0923
|
|||
|
477a00db1a
|
|||
|
523108e442
|
|||
|
9b8cefcd72
|
|||
|
9df5e1bd8f
|
|||
|
6a806f857c
|
|||
|
642e22759f
|
|||
|
65a032f961
|
|||
|
809c73c3a3
|
|||
|
c79e4dd664
|
|||
|
5cec57e06d
|
|||
|
3e3b8d529c
|
|||
|
509f1387de
|
|||
|
e1c495d82d
|
|||
|
ade6bf0002
|
@@ -39,19 +39,17 @@ jobs:
|
|||||||
)"
|
)"
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build extension archive
|
- name: Build extension archives
|
||||||
run: |
|
run: |
|
||||||
rm -rf extension-package
|
python scripts/package_extension.py --out "dist/browser-cli-extension-testing-v${{ steps.version.outputs.version }}.zip"
|
||||||
mkdir -p dist extension-package
|
python scripts/package_extension.py --webstore --out "dist/browser-cli-extension-webstore-v${{ steps.version.outputs.version }}.zip"
|
||||||
cp extension/manifest.json extension/background.js extension/content.js extension/icon.svg extension-package/
|
|
||||||
cp -R extension/icons extension-package/icons
|
|
||||||
cd extension-package
|
|
||||||
zip -r "../dist/browser-cli-extension-v${{ steps.version.outputs.version }}.zip" .
|
|
||||||
|
|
||||||
- name: Publish extension release asset
|
- name: Publish extension release assets
|
||||||
env:
|
env:
|
||||||
ACTION_ACCESS_TOKEN: ${{ secrets.ACTION_ACCESS_TOKEN }}
|
ACTION_ACCESS_TOKEN: ${{ secrets.ACTION_ACCESS_TOKEN }}
|
||||||
ASSET_NAME: browser-cli-extension-v${{ steps.version.outputs.version }}.zip
|
ASSET_NAMES: |
|
||||||
|
browser-cli-extension-testing-v${{ steps.version.outputs.version }}.zip
|
||||||
|
browser-cli-extension-webstore-v${{ steps.version.outputs.version }}.zip
|
||||||
EXTENSION_VERSION: ${{ steps.version.outputs.version }}
|
EXTENSION_VERSION: ${{ steps.version.outputs.version }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||||
@@ -59,15 +57,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
asset_path="dist/browser-cli-extension-v${EXTENSION_VERSION}.zip"
|
|
||||||
asset_name="$(basename "$asset_path")"
|
|
||||||
tag_name="v${EXTENSION_VERSION}"
|
tag_name="v${EXTENSION_VERSION}"
|
||||||
api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
if [ ! -f "$asset_path" ]; then
|
while IFS= read -r asset_name; do
|
||||||
echo "Missing asset: $asset_path" >&2
|
[ -n "$asset_name" ] || continue
|
||||||
exit 1
|
asset_path="dist/${asset_name}"
|
||||||
fi
|
if [ ! -f "$asset_path" ]; then
|
||||||
|
echo "Missing asset: $asset_path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done <<EOF
|
||||||
|
${ASSET_NAMES}
|
||||||
|
EOF
|
||||||
|
|
||||||
release_body="$(mktemp)"
|
release_body="$(mktemp)"
|
||||||
create_body="$(mktemp)"
|
create_body="$(mktemp)"
|
||||||
@@ -146,7 +148,11 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
existing_asset_id="$(python - "$release_body" <<'PY'
|
while IFS= read -r asset_name; do
|
||||||
|
[ -n "$asset_name" ] || continue
|
||||||
|
asset_path="dist/${asset_name}"
|
||||||
|
|
||||||
|
existing_asset_id="$(ASSET_NAME="$asset_name" python - "$release_body" <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -162,19 +168,22 @@ jobs:
|
|||||||
else:
|
else:
|
||||||
print("")
|
print("")
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
|
|
||||||
|
if [ -n "$existing_asset_id" ]; then
|
||||||
|
curl --silent --show-error \
|
||||||
|
--request DELETE \
|
||||||
|
--header "Authorization: token ${ACTION_ACCESS_TOKEN}" \
|
||||||
|
--header "Accept: application/json" \
|
||||||
|
"${api_base}/releases/${release_id}/assets/${existing_asset_id}"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "$existing_asset_id" ]; then
|
|
||||||
curl --silent --show-error \
|
curl --silent --show-error \
|
||||||
--request DELETE \
|
--request POST \
|
||||||
--header "Authorization: token ${ACTION_ACCESS_TOKEN}" \
|
--header "Authorization: token ${ACTION_ACCESS_TOKEN}" \
|
||||||
--header "Accept: application/json" \
|
--header "Accept: application/json" \
|
||||||
"${api_base}/releases/${release_id}/assets/${existing_asset_id}"
|
--form "attachment=@${asset_path}" \
|
||||||
fi
|
"${api_base}/releases/${release_id}/assets?name=${asset_name}"
|
||||||
|
done <<EOF
|
||||||
curl --silent --show-error \
|
${ASSET_NAMES}
|
||||||
--request POST \
|
EOF
|
||||||
--header "Authorization: token ${ACTION_ACCESS_TOKEN}" \
|
|
||||||
--header "Accept: application/json" \
|
|
||||||
--form "attachment=@${asset_path}" \
|
|
||||||
"${api_base}/releases/${release_id}/assets?name=${asset_name}"
|
|
||||||
|
|||||||
@@ -17,8 +17,31 @@ jobs:
|
|||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
- name: Build package
|
- name: Build Gitea package
|
||||||
run: uv build
|
run: |
|
||||||
|
# Keep the public/PyPI distribution as real-browser-cli in the repo,
|
||||||
|
# but publish the private Gitea package under browser-cli.
|
||||||
|
python - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
replacements = {
|
||||||
|
Path("pyproject.toml"): (
|
||||||
|
'name = "real-browser-cli"',
|
||||||
|
'name = "browser-cli"',
|
||||||
|
),
|
||||||
|
Path("browser_cli/constants.py"): (
|
||||||
|
'PYPI_PACKAGE_NAME = "real-browser-cli"',
|
||||||
|
'PYPI_PACKAGE_NAME = "browser-cli"',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, (old, new) in replacements.items():
|
||||||
|
text = path.read_text()
|
||||||
|
if old not in text:
|
||||||
|
raise SystemExit(f"expected text not found in {path}: {old}")
|
||||||
|
path.write_text(text.replace(old, new, 1))
|
||||||
|
PY
|
||||||
|
uv build
|
||||||
|
|
||||||
- name: Publish to Gitea
|
- name: Publish to Gitea
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ extension/test-dist/
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Local secrets / signing keys
|
||||||
|
secrets/
|
||||||
|
*.pem
|
||||||
|
*.pem.gpg
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[submodule "servicelink"]
|
|
||||||
path = servicelink
|
|
||||||
url = git@git.yiprawr.dev:submodules/servicelink.git
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# PolyForm Noncommercial License 1.0.0
|
||||||
|
|
||||||
|
Required Notice: Copyright (c) 2026 Daniel Dolezal
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
||||||
|
|
||||||
|
## Copyright License
|
||||||
|
|
||||||
|
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
||||||
|
|
||||||
|
## Distribution License
|
||||||
|
|
||||||
|
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
||||||
|
|
||||||
|
## Notices
|
||||||
|
|
||||||
|
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
||||||
|
|
||||||
|
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||||
|
|
||||||
|
## Changes and New Works License
|
||||||
|
|
||||||
|
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
||||||
|
|
||||||
|
## Patent License
|
||||||
|
|
||||||
|
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
||||||
|
|
||||||
|
## Noncommercial Purposes
|
||||||
|
|
||||||
|
Any noncommercial purpose is a permitted purpose.
|
||||||
|
|
||||||
|
## Personal Uses
|
||||||
|
|
||||||
|
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
||||||
|
|
||||||
|
## Noncommercial Organizations
|
||||||
|
|
||||||
|
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
||||||
|
|
||||||
|
## Fair Use
|
||||||
|
|
||||||
|
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
||||||
|
|
||||||
|
## No Other Rights
|
||||||
|
|
||||||
|
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
||||||
|
|
||||||
|
## Patent Defense
|
||||||
|
|
||||||
|
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||||
|
|
||||||
|
## Violations
|
||||||
|
|
||||||
|
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
||||||
|
|
||||||
|
## No Liability
|
||||||
|
|
||||||
|
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
||||||
|
|
||||||
|
**You** refers to the individual or entity agreeing to these terms.
|
||||||
|
|
||||||
|
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization.
|
||||||
|
|
||||||
|
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||||
|
|
||||||
|
**Your licenses** are all the licenses granted to you for the software under these terms.
|
||||||
|
|
||||||
|
**Use** means anything you do with the software requiring one of your licenses.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Privacy Policy for browser-cli
|
||||||
|
Last updated: 2026-06-14
|
||||||
|
|
||||||
|
browser-cli does not collect, transmit, sell, or share user data with the developer or any third party.
|
||||||
|
|
||||||
|
browser-cli is a local browser automation tool. The browser extension communicates with the locally installed browser-cli native messaging host so the user can control their own browser through the command line or Python SDK.
|
||||||
|
|
||||||
|
## Local data access
|
||||||
|
Depending on the command explicitly run by the user, browser-cli may locally access browser data such as:
|
||||||
|
- tab URLs, titles, status, and window or tab group information
|
||||||
|
- page content, links, images, HTML, text, screenshots, or DOM data
|
||||||
|
- cookies, local storage, session storage, and saved browser-cli session data
|
||||||
|
|
||||||
|
This access happens only to perform the command requested by the user. The data stays on the user's device unless the user explicitly configures browser-cli to connect to another machine they control.
|
||||||
|
|
||||||
|
## Remote control mode
|
||||||
|
browser-cli includes an optional remote control mode. If the user enables this mode, command data may be transmitted between the user's configured browser-cli client and server endpoints. This is user-configured infrastructure. The developer does not receive or operate these endpoints.
|
||||||
|
|
||||||
|
## No analytics or tracking
|
||||||
|
browser-cli does not use analytics, telemetry, advertising, behavioral tracking, or remote code. The extension does not send data to the developer.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
For privacy questions or security reports, please open an issue in the project repository or contact the project maintainer through the repository hosting platform.
|
||||||
@@ -50,18 +50,42 @@ Every response:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi
|
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
|
||||||
|
|
||||||
|
### Install with uv
|
||||||
|
Once published on PyPI, install the CLI as a uv tool:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv tool install real-browser-cli
|
||||||
|
browser-cli --version
|
||||||
|
browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
The PyPI package is named `real-browser-cli`; the installed command is still `browser-cli`.
|
||||||
|
|
||||||
|
For better remote-response compression, install the optional `fast` extra:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv tool install "real-browser-cli[fast]"
|
||||||
|
```
|
||||||
|
|
||||||
|
To upgrade later:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv tool upgrade real-browser-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install from source
|
||||||
```sh
|
```sh
|
||||||
git clone <repo>
|
git clone <repo>
|
||||||
cd browser-cli
|
cd browser-cli
|
||||||
uv sync
|
uv sync
|
||||||
uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi
|
uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
|
||||||
```
|
```
|
||||||
|
|
||||||
The `install` command will:
|
The `install` command will:
|
||||||
1. Ask you to load the browser-specific extension package
|
1. Ask you to load the browser-specific extension package
|
||||||
2. For Chromium-family browsers, ask you to paste the extension ID shown on the extension card
|
2. Show the stable extension ID used by that browser family
|
||||||
3. Write the native messaging manifest to your OS so the browser can find the host
|
3. Write the native messaging manifest to your OS so the browser can find the host
|
||||||
4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
|
4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
|
||||||
|
|
||||||
@@ -138,9 +162,9 @@ Important: profile aliases are browser-instance aliases, not window aliases. Win
|
|||||||
### Navigation (`nav`)
|
### Navigation (`nav`)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Open a URL
|
# Open a URL (no focus stealing by default)
|
||||||
browser-cli nav open https://example.com
|
browser-cli nav open https://example.com
|
||||||
browser-cli nav open https://example.com --bg # background, no focus
|
browser-cli nav open https://example.com --focus # bring opened tab/window forward
|
||||||
browser-cli nav open https://example.com --window work # into a named window
|
browser-cli nav open https://example.com --window work # into a named window
|
||||||
browser-cli nav open https://example.com --group research # into a tab group (name or ID)
|
browser-cli nav open https://example.com --group research # into a tab group (name or ID)
|
||||||
|
|
||||||
@@ -163,7 +187,7 @@ Each search command opens the search results in your browser using the same flag
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
browser-cli search google openai api
|
browser-cli search google openai api
|
||||||
browser-cli search brave rust iterators --bg
|
browser-cli search brave rust iterators
|
||||||
browser-cli search ddg tab groups --window work
|
browser-cli search ddg tab groups --window work
|
||||||
browser-cli search youtube browser automation
|
browser-cli search youtube browser automation
|
||||||
browser-cli search yt lo fi
|
browser-cli search yt lo fi
|
||||||
@@ -249,7 +273,7 @@ These commands run on the **active tab**. The tab must be on a regular `http://`
|
|||||||
browser-cli dom query "h1" # return elements matching CSS selector
|
browser-cli dom query "h1" # return elements matching CSS selector
|
||||||
browser-cli dom text "h1" # get text content of matching elements
|
browser-cli dom text "h1" # get text content of matching elements
|
||||||
browser-cli dom attr "a" href # get attribute value from elements
|
browser-cli dom attr "a" href # get attribute value from elements
|
||||||
browser-cli dom exists ".cookie-banner" # exits 0 if found, 1 if not
|
browser-cli dom exists ".modal-banner" # exits 0 if found, 1 if not
|
||||||
browser-cli dom click ".accept-button" # click an element
|
browser-cli dom click ".accept-button" # click an element
|
||||||
browser-cli dom type "#search" "hello" # type text into an input
|
browser-cli dom type "#search" "hello" # type text into an input
|
||||||
```
|
```
|
||||||
@@ -363,7 +387,7 @@ b.windows.close(1)
|
|||||||
elements = b.dom.query("h2") # list of { tag, text, attrs }
|
elements = b.dom.query("h2") # list of { tag, text, attrs }
|
||||||
texts = b.dom.text(".article p") # list of strings
|
texts = b.dom.text(".article p") # list of strings
|
||||||
attrs = b.dom.attr("a", "href") # list of strings
|
attrs = b.dom.attr("a", "href") # list of strings
|
||||||
exists = b.dom.exists(".cookie-banner")# bool
|
exists = b.dom.exists(".modal-banner") # bool
|
||||||
b.dom.click(".accept-button")
|
b.dom.click(".accept-button")
|
||||||
b.dom.type("#search", "hello world")
|
b.dom.type("#search", "hello world")
|
||||||
b.dom.wait_for("#results", visible=True, timeout=10)
|
b.dom.wait_for("#results", visible=True, timeout=10)
|
||||||
@@ -376,11 +400,10 @@ text = b.extract.text() # string
|
|||||||
data = b.extract.json("#app-data") # parsed Python object
|
data = b.extract.json("#app-data") # parsed Python object
|
||||||
md = b.extract.markdown("article")
|
md = b.extract.markdown("article")
|
||||||
|
|
||||||
# Page / storage / cookies
|
# Page / storage
|
||||||
info = b.page.info()
|
info = b.page.info()
|
||||||
b.storage.set("token", "abc")
|
b.storage.set("token", "abc")
|
||||||
val = b.storage.get("token")
|
val = b.storage.get("token")
|
||||||
cookies = b.cookies.list(domain="example.com")
|
|
||||||
|
|
||||||
# Sessions ── b.session
|
# Sessions ── b.session
|
||||||
b.session.save("before-meeting")
|
b.session.save("before-meeting")
|
||||||
@@ -489,11 +512,40 @@ npm run check:extension
|
|||||||
|
|
||||||
The extension source lives in `extension/src/`. `extension/background.js` and `extension/content-dispatch.js` are generated and ignored by git. Run `npm run build:extension` before using `Load unpacked` with `extension/`. On NixOS, use `nix-shell` first if npm is not installed globally.
|
The extension source lives in `extension/src/`. `extension/background.js` and `extension/content-dispatch.js` are generated and ignored by git. Run `npm run build:extension` before using `Load unpacked` with `extension/`. On NixOS, use `nix-shell` first if npm is not installed globally.
|
||||||
|
|
||||||
|
Packaging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
|
||||||
|
npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
|
||||||
|
npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
|
||||||
|
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For verified CRX uploads, create a dedicated RSA upload key once and protect it with your GPG key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/setup_verified_crx_key.sh --recipient '<your GPG key id or email>'
|
||||||
|
# Add the generated public key in Chrome Developer Dashboard -> Package -> Verified uploads.
|
||||||
|
npm run package:extension:webstore:verified
|
||||||
|
```
|
||||||
|
|
||||||
|
The verified-upload private key is not a GPG key; Chrome requires an RSA CRX signing key. GPG is used here to encrypt that RSA private key at rest. The signed `*.crx` from `dist/` is the upload artifact after verified uploads are enabled. For Firefox, use the `*-firefox-*` zip.
|
||||||
|
|
||||||
|
For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
|
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
|
||||||
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
|
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
|
||||||
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
|
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
|
||||||
- **Linux and macOS only** — Windows native messaging paths are not yet handled.
|
- **Firefox support is experimental**. Basic tab/window/navigation/native-messaging support is wired, including tab-group APIs on supported Firefox versions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
PolyForm Noncommercial License 1.0.0. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
Commercial use is not permitted under this license. For commercial licensing, contact the project maintainer.
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ Commands are grouped into namespaces on the client:
|
|||||||
b.extract content extraction (links, images, text, json, markdown)
|
b.extract content extraction (links, images, text, json, markdown)
|
||||||
b.page page info
|
b.page page info
|
||||||
b.storage localStorage / sessionStorage
|
b.storage localStorage / sessionStorage
|
||||||
b.cookies cookies (list, get, set)
|
|
||||||
b.session sessions (save, load, list, diff, ...)
|
b.session sessions (save, load, list, diff, ...)
|
||||||
b.perf performance profile + background jobs
|
b.perf performance profile + background jobs
|
||||||
b.extension control the extension itself
|
b.extension control the extension itself
|
||||||
@@ -37,11 +36,10 @@ Commands are grouped into namespaces on the client:
|
|||||||
"""
|
"""
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, send_command_async
|
from browser_cli.client import active_browser_targets, remote_browser_targets, remote_targets_for_alias, send_command, send_command_async
|
||||||
from browser_cli.errors import BrowserNotConnected
|
from browser_cli.errors import BrowserNotConnected
|
||||||
from browser_cli.models import BrowserCounts, Group, Tab
|
from browser_cli.models import BrowserCounts, Group, Tab
|
||||||
from browser_cli.sdk import (
|
from browser_cli.sdk import (
|
||||||
CookiesNS,
|
|
||||||
DecoratorsNS,
|
DecoratorsNS,
|
||||||
DomNS,
|
DomNS,
|
||||||
ExtensionNS,
|
ExtensionNS,
|
||||||
@@ -85,7 +83,6 @@ class BrowserCLI(FactoryMixin, RoutingMixin):
|
|||||||
extract: ExtractNS
|
extract: ExtractNS
|
||||||
page: PageNS
|
page: PageNS
|
||||||
storage: StorageNS
|
storage: StorageNS
|
||||||
cookies: CookiesNS
|
|
||||||
session: SessionNS
|
session: SessionNS
|
||||||
perf: PerfNS
|
perf: PerfNS
|
||||||
extension: ExtensionNS
|
extension: ExtensionNS
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
import functools
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import TypeVar
|
from typing import TypeVar, cast
|
||||||
|
|
||||||
from browser_cli.models import Group, Tab
|
from browser_cli.models import Group, Tab
|
||||||
from browser_cli.sdk import NAMESPACE_NAMES
|
from browser_cli.sdk import NAMESPACE_NAMES
|
||||||
@@ -74,7 +74,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
|
|||||||
finally:
|
finally:
|
||||||
if cleanup is not None:
|
if cleanup is not None:
|
||||||
await self._maybe_await(cleanup(value))
|
await self._maybe_await(cleanup(value))
|
||||||
return wrapper # type: ignore[return-value]
|
return cast(F, wrapper)
|
||||||
return decorator(func) if func is not None else decorator
|
return decorator(func) if func is not None else decorator
|
||||||
|
|
||||||
def new_tab(
|
def new_tab(
|
||||||
@@ -84,6 +84,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
|
|||||||
wait: bool = False,
|
wait: bool = False,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
background: bool = False,
|
background: bool = False,
|
||||||
|
focus: bool = False,
|
||||||
window: str | None = None,
|
window: str | None = None,
|
||||||
group: str | None = None,
|
group: str | None = None,
|
||||||
close: bool = False,
|
close: bool = False,
|
||||||
@@ -95,6 +96,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
|
|||||||
wait=wait,
|
wait=wait,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
background=background,
|
background=background,
|
||||||
|
focus=focus,
|
||||||
window=window,
|
window=window,
|
||||||
group=group,
|
group=group,
|
||||||
)
|
)
|
||||||
@@ -115,7 +117,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
|
|||||||
finally:
|
finally:
|
||||||
if previous:
|
if previous:
|
||||||
await self._c.perf.set_profile(previous)
|
await self._c.perf.set_profile(previous)
|
||||||
return wrapper # type: ignore[return-value]
|
return cast(F, wrapper)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
def retry(
|
def retry(
|
||||||
@@ -140,8 +142,8 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
|
|||||||
raise
|
raise
|
||||||
if delay > 0:
|
if delay > 0:
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
raise last_error # type: ignore[misc]
|
raise cast(BaseException, last_error)
|
||||||
return wrapper # type: ignore[return-value]
|
return cast(F, wrapper)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
class AsyncBrowserCLI:
|
class AsyncBrowserCLI:
|
||||||
@@ -218,18 +220,40 @@ class AsyncBrowserCLI:
|
|||||||
async def clients(self) -> list[dict]:
|
async def clients(self) -> list[dict]:
|
||||||
return await self._cmd("clients.list", {})
|
return await self._cmd("clients.list", {})
|
||||||
|
|
||||||
def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab:
|
def tab_from(
|
||||||
|
self,
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
browser_profile: str | None = None,
|
||||||
|
browser_name: str | None = None,
|
||||||
|
browser_remote: str | None = None,
|
||||||
|
browser_type: str | None = None,
|
||||||
|
browser_group: str | None = None,
|
||||||
|
) -> Tab:
|
||||||
return self._sync.tab_from(
|
return self._sync.tab_from(
|
||||||
data,
|
data,
|
||||||
browser_profile=browser_profile,
|
browser_profile=browser_profile,
|
||||||
browser_name=browser_name,
|
browser_name=browser_name,
|
||||||
browser_remote=browser_remote,
|
browser_remote=browser_remote,
|
||||||
|
browser_type=browser_type,
|
||||||
|
browser_group=browser_group,
|
||||||
)
|
)
|
||||||
|
|
||||||
def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group:
|
def group_from(
|
||||||
|
self,
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
browser_profile: str | None = None,
|
||||||
|
browser_name: str | None = None,
|
||||||
|
browser_remote: str | None = None,
|
||||||
|
browser_type: str | None = None,
|
||||||
|
browser_group: str | None = None,
|
||||||
|
) -> Group:
|
||||||
return self._sync.group_from(
|
return self._sync.group_from(
|
||||||
data,
|
data,
|
||||||
browser_profile=browser_profile,
|
browser_profile=browser_profile,
|
||||||
browser_name=browser_name,
|
browser_name=browser_name,
|
||||||
browser_remote=browser_remote,
|
browser_remote=browser_remote,
|
||||||
|
browser_type=browser_type,
|
||||||
|
browser_group=browser_group,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
"""Ed25519 keypair management, ML-KEM key exchange, and auth helpers."""
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
||||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
||||||
from cryptography.hazmat.primitives.serialization import (
|
|
||||||
Encoding,
|
|
||||||
NoEncryption,
|
|
||||||
PrivateFormat,
|
|
||||||
PublicFormat,
|
|
||||||
load_pem_private_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
from browser_cli.constants import (
|
|
||||||
DEFAULT_AUTHORIZED_KEYS_PATH,
|
|
||||||
DEFAULT_KEY_PATH,
|
|
||||||
PQ_KEX_ALG,
|
|
||||||
PQ_TRANSPORT_ALG,
|
|
||||||
SSH_AGENT_IDENTITIES_ANSWER,
|
|
||||||
SSH_AGENT_SIGN_RESPONSE,
|
|
||||||
SSH_AGENTC_REQUEST_IDENTITIES,
|
|
||||||
SSH_AGENTC_SIGN_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _pack_str(s: bytes) -> bytes:
|
|
||||||
return struct.pack(">I", len(s)) + s
|
|
||||||
|
|
||||||
def _unpack_str(data: bytes, off: int) -> tuple[bytes, int]:
|
|
||||||
n = struct.unpack_from(">I", data, off)[0]
|
|
||||||
return data[off + 4 : off + 4 + n], off + 4 + n
|
|
||||||
|
|
||||||
def _agent_roundtrip(msg: bytes) -> bytes:
|
|
||||||
sock_path = os.environ.get("SSH_AUTH_SOCK")
|
|
||||||
if not sock_path:
|
|
||||||
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
||||||
sock.settimeout(10)
|
|
||||||
sock.connect(sock_path)
|
|
||||||
sock.sendall(struct.pack(">I", len(msg)) + msg)
|
|
||||||
raw_len = b""
|
|
||||||
while len(raw_len) < 4:
|
|
||||||
chunk = sock.recv(4 - len(raw_len))
|
|
||||||
if not chunk:
|
|
||||||
raise RuntimeError("SSH agent closed connection")
|
|
||||||
raw_len += chunk
|
|
||||||
n = struct.unpack(">I", raw_len)[0]
|
|
||||||
resp = b""
|
|
||||||
while len(resp) < n:
|
|
||||||
chunk = sock.recv(n - len(resp))
|
|
||||||
if not chunk:
|
|
||||||
raise RuntimeError("SSH agent closed connection mid-response")
|
|
||||||
resp += chunk
|
|
||||||
return resp
|
|
||||||
|
|
||||||
# ── AgentKey ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AgentKey:
|
|
||||||
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
|
|
||||||
blob: bytes
|
|
||||||
comment: str
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pubkey_bytes(self) -> bytes:
|
|
||||||
_algo, off = _unpack_str(self.blob, 0)
|
|
||||||
key_bytes, _ = _unpack_str(self.blob, off)
|
|
||||||
return key_bytes
|
|
||||||
|
|
||||||
# ── Agent helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def agent_list_keys() -> list[AgentKey]:
|
|
||||||
"""Return all Ed25519 keys currently held by the SSH agent."""
|
|
||||||
resp = _agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
|
|
||||||
if resp[0] != SSH_AGENT_IDENTITIES_ANSWER:
|
|
||||||
raise RuntimeError(f"Unexpected agent response: {resp[0]}")
|
|
||||||
n_keys = struct.unpack_from(">I", resp, 1)[0]
|
|
||||||
keys: list[AgentKey] = []
|
|
||||||
off = 5
|
|
||||||
for _ in range(n_keys):
|
|
||||||
blob, off = _unpack_str(resp, off)
|
|
||||||
comment, off = _unpack_str(resp, off)
|
|
||||||
algo, _ = _unpack_str(blob, 0)
|
|
||||||
if algo == b"ssh-ed25519":
|
|
||||||
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
|
|
||||||
return keys
|
|
||||||
|
|
||||||
def agent_find_key(selector: str | None = None) -> AgentKey | None:
|
|
||||||
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
|
|
||||||
try:
|
|
||||||
keys = agent_list_keys()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
for key in keys:
|
|
||||||
if key.comment == "(none)":
|
|
||||||
continue
|
|
||||||
if selector is None or selector in key.comment:
|
|
||||||
return key
|
|
||||||
return None
|
|
||||||
|
|
||||||
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
|
|
||||||
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
|
|
||||||
msg = (
|
|
||||||
bytes([SSH_AGENTC_SIGN_REQUEST])
|
|
||||||
+ _pack_str(key.blob)
|
|
||||||
+ _pack_str(data)
|
|
||||||
+ struct.pack(">I", 0)
|
|
||||||
)
|
|
||||||
resp = _agent_roundtrip(msg)
|
|
||||||
if resp[0] != SSH_AGENT_SIGN_RESPONSE:
|
|
||||||
raise RuntimeError(f"SSH agent refused to sign (response code {resp[0]})")
|
|
||||||
sig_blob, _ = _unpack_str(resp, 1)
|
|
||||||
_algo, soff = _unpack_str(sig_blob, 0)
|
|
||||||
raw_sig, _ = _unpack_str(sig_blob, soff)
|
|
||||||
if len(raw_sig) != 64:
|
|
||||||
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
|
|
||||||
return raw_sig
|
|
||||||
|
|
||||||
# ── File-based key helpers ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def generate_keypair() -> tuple[bytes, str]:
|
|
||||||
"""Return (private_key_pem_bytes, public_key_hex)."""
|
|
||||||
priv = Ed25519PrivateKey.generate()
|
|
||||||
pem = priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
|
||||||
pub_hex = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
|
||||||
return pem, pub_hex
|
|
||||||
|
|
||||||
def load_private_key(path: Path) -> Ed25519PrivateKey:
|
|
||||||
return load_pem_private_key(path.read_bytes(), password=None)
|
|
||||||
|
|
||||||
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
|
|
||||||
if isinstance(key, AgentKey):
|
|
||||||
return key.pubkey_bytes.hex()
|
|
||||||
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
|
||||||
|
|
||||||
# ── Canonical payload + sign/verify ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def canonical_payload(msg: dict) -> bytes:
|
|
||||||
"""Deterministic JSON encoding of msg without auth protocol fields."""
|
|
||||||
return json.dumps(
|
|
||||||
{k: v for k, v in msg.items() if k not in {"pubkey", "sig", "pq_kex"}},
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
def _auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
|
||||||
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
|
|
||||||
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
|
||||||
if pq_shared_secret is not None:
|
|
||||||
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
|
|
||||||
return data
|
|
||||||
|
|
||||||
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
|
||||||
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
|
|
||||||
data = _auth_message(nonce, msg, pq_shared_secret)
|
|
||||||
if isinstance(key, AgentKey):
|
|
||||||
return agent_sign_raw(key, data)
|
|
||||||
return key.sign(data)
|
|
||||||
|
|
||||||
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
|
|
||||||
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
|
|
||||||
try:
|
|
||||||
pub_bytes = bytes.fromhex(pub_hex)
|
|
||||||
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
|
|
||||||
pub_key.verify(bytes.fromhex(sig_hex), _auth_message(nonce, msg, pq_shared_secret))
|
|
||||||
return True
|
|
||||||
except (InvalidSignature, ValueError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ── Post-quantum key exchange (ML-KEM / Kyber) ────────────────────────────────
|
|
||||||
|
|
||||||
def pq_kex_server_keypair():
|
|
||||||
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
|
|
||||||
|
|
||||||
Returns ``None`` when the installed cryptography/OpenSSL backend does not
|
|
||||||
support ML-KEM yet. The serve/client protocol treats this as graceful
|
|
||||||
downgrade instead of breaking local installs on older OpenSSL builds.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import mlkem
|
|
||||||
priv = mlkem.MLKEM768PrivateKey.generate()
|
|
||||||
pub = priv.public_key().public_bytes_raw()
|
|
||||||
return priv, pub
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
|
|
||||||
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import mlkem
|
|
||||||
pub = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
|
|
||||||
shared_secret, ciphertext = pub.encapsulate()
|
|
||||||
return ciphertext.hex(), shared_secret
|
|
||||||
|
|
||||||
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
|
|
||||||
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
|
|
||||||
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
|
|
||||||
|
|
||||||
def _pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
|
|
||||||
return HKDF(
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
length=32,
|
|
||||||
salt=None,
|
|
||||||
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
|
|
||||||
).derive(shared_secret)
|
|
||||||
|
|
||||||
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
|
|
||||||
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
|
|
||||||
nonce = secrets.token_bytes(12)
|
|
||||||
key = _pq_transport_key(shared_secret, direction)
|
|
||||||
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
|
|
||||||
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
|
|
||||||
|
|
||||||
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
|
|
||||||
"""Decrypt an app-layer frame produced by pq_encrypt()."""
|
|
||||||
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
|
|
||||||
raise ValueError("unsupported encrypted transport envelope")
|
|
||||||
key = _pq_transport_key(shared_secret, direction)
|
|
||||||
return ChaCha20Poly1305(key).decrypt(
|
|
||||||
bytes.fromhex(str(envelope["nonce"])),
|
|
||||||
bytes.fromhex(str(envelope["ciphertext"])),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def new_nonce() -> str:
|
|
||||||
return secrets.token_hex(32)
|
|
||||||
|
|
||||||
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
|
|
||||||
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
|
|
||||||
if not path.exists():
|
|
||||||
return []
|
|
||||||
result = []
|
|
||||||
for line in path.read_text(encoding="utf-8").splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
parts = line.split(None, 1)
|
|
||||||
pubkey = parts[0]
|
|
||||||
name = parts[1].strip() if len(parts) > 1 else ""
|
|
||||||
result.append((pubkey, name))
|
|
||||||
return result
|
|
||||||
|
|
||||||
def load_authorized_keys(path: Path) -> list[str]:
|
|
||||||
return [pk for pk, _ in load_authorized_keys_with_names(path)]
|
|
||||||
|
|
||||||
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
|
|
||||||
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
|
|
||||||
if pub_hex in existing:
|
|
||||||
return False
|
|
||||||
line = (f"{pub_hex} {name}".rstrip()) + "\n"
|
|
||||||
with open(path, "a", encoding="utf-8") as f:
|
|
||||||
f.write(line)
|
|
||||||
return True
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Public auth API for browser-cli.
|
||||||
|
|
||||||
|
Implementation lives in focused modules:
|
||||||
|
- ``auth.agent``: SSH-agent/YubiKey helpers
|
||||||
|
- ``auth.keys``: file keys and authorized_keys management
|
||||||
|
- ``auth.signing``: canonical payload signing/verification
|
||||||
|
- ``auth.pq``: ML-KEM KEX and encrypted transport helpers
|
||||||
|
"""
|
||||||
|
from browser_cli.auth.agent import (
|
||||||
|
AgentKey,
|
||||||
|
agent_find_key,
|
||||||
|
agent_list_keys,
|
||||||
|
agent_roundtrip as _agent_roundtrip,
|
||||||
|
agent_sign_raw,
|
||||||
|
pack_ssh_string as _pack_str,
|
||||||
|
unpack_ssh_string as _unpack_str,
|
||||||
|
)
|
||||||
|
from browser_cli.auth.keys import (
|
||||||
|
add_authorized_key,
|
||||||
|
generate_keypair,
|
||||||
|
load_authorized_keys,
|
||||||
|
load_authorized_keys_with_names,
|
||||||
|
load_private_key,
|
||||||
|
public_key_hex,
|
||||||
|
)
|
||||||
|
from browser_cli.auth.pq import (
|
||||||
|
new_nonce,
|
||||||
|
pq_decrypt,
|
||||||
|
pq_encrypt,
|
||||||
|
pq_kex_client_encapsulate,
|
||||||
|
pq_kex_server_decapsulate,
|
||||||
|
pq_kex_server_keypair,
|
||||||
|
pq_transport_key as _pq_transport_key,
|
||||||
|
)
|
||||||
|
from browser_cli.auth.signing import (
|
||||||
|
auth_message as _auth_message,
|
||||||
|
canonical_payload,
|
||||||
|
sign,
|
||||||
|
verify,
|
||||||
|
)
|
||||||
|
from browser_cli.constants import DEFAULT_AUTHORIZED_KEYS_PATH, DEFAULT_KEY_PATH, PQ_KEX_ALG, PQ_TRANSPORT_ALG
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentKey",
|
||||||
|
"DEFAULT_AUTHORIZED_KEYS_PATH",
|
||||||
|
"DEFAULT_KEY_PATH",
|
||||||
|
"PQ_KEX_ALG",
|
||||||
|
"PQ_TRANSPORT_ALG",
|
||||||
|
"add_authorized_key",
|
||||||
|
"agent_find_key",
|
||||||
|
"agent_list_keys",
|
||||||
|
"agent_sign_raw",
|
||||||
|
"canonical_payload",
|
||||||
|
"generate_keypair",
|
||||||
|
"load_authorized_keys",
|
||||||
|
"load_authorized_keys_with_names",
|
||||||
|
"load_private_key",
|
||||||
|
"new_nonce",
|
||||||
|
"pq_decrypt",
|
||||||
|
"pq_encrypt",
|
||||||
|
"pq_kex_client_encapsulate",
|
||||||
|
"pq_kex_server_decapsulate",
|
||||||
|
"pq_kex_server_keypair",
|
||||||
|
"public_key_hex",
|
||||||
|
"sign",
|
||||||
|
"verify",
|
||||||
|
]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""SSH-agent backed Ed25519 key helpers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from browser_cli.constants import (
|
||||||
|
SSH_AGENT_IDENTITIES_ANSWER,
|
||||||
|
SSH_AGENT_SIGN_RESPONSE,
|
||||||
|
SSH_AGENTC_REQUEST_IDENTITIES,
|
||||||
|
SSH_AGENTC_SIGN_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
def pack_ssh_string(value: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(value)) + value
|
||||||
|
|
||||||
|
def unpack_ssh_string(data: bytes, offset: int) -> tuple[bytes, int]:
|
||||||
|
length = struct.unpack_from(">I", data, offset)[0]
|
||||||
|
return data[offset + 4 : offset + 4 + length], offset + 4 + length
|
||||||
|
|
||||||
|
def agent_roundtrip(msg: bytes) -> bytes:
|
||||||
|
sock_path = os.environ.get("SSH_AUTH_SOCK")
|
||||||
|
if not sock_path:
|
||||||
|
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.settimeout(10)
|
||||||
|
sock.connect(sock_path)
|
||||||
|
sock.sendall(struct.pack(">I", len(msg)) + msg)
|
||||||
|
raw_len = b""
|
||||||
|
while len(raw_len) < 4:
|
||||||
|
chunk = sock.recv(4 - len(raw_len))
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("SSH agent closed connection")
|
||||||
|
raw_len += chunk
|
||||||
|
length = struct.unpack(">I", raw_len)[0]
|
||||||
|
response = b""
|
||||||
|
while len(response) < length:
|
||||||
|
chunk = sock.recv(length - len(response))
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("SSH agent closed connection mid-response")
|
||||||
|
response += chunk
|
||||||
|
return response
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentKey:
|
||||||
|
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
|
||||||
|
blob: bytes
|
||||||
|
comment: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pubkey_bytes(self) -> bytes:
|
||||||
|
_algo, offset = unpack_ssh_string(self.blob, 0)
|
||||||
|
key_bytes, _ = unpack_ssh_string(self.blob, offset)
|
||||||
|
return key_bytes
|
||||||
|
|
||||||
|
def agent_list_keys() -> list[AgentKey]:
|
||||||
|
"""Return all Ed25519 keys currently held by the SSH agent."""
|
||||||
|
response = agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
|
||||||
|
if response[0] != SSH_AGENT_IDENTITIES_ANSWER:
|
||||||
|
raise RuntimeError(f"Unexpected agent response: {response[0]}")
|
||||||
|
key_count = struct.unpack_from(">I", response, 1)[0]
|
||||||
|
keys: list[AgentKey] = []
|
||||||
|
offset = 5
|
||||||
|
for _ in range(key_count):
|
||||||
|
blob, offset = unpack_ssh_string(response, offset)
|
||||||
|
comment, offset = unpack_ssh_string(response, offset)
|
||||||
|
algo, _ = unpack_ssh_string(blob, 0)
|
||||||
|
if algo == b"ssh-ed25519":
|
||||||
|
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
|
||||||
|
return keys
|
||||||
|
|
||||||
|
def agent_find_key(selector: str | None = None) -> AgentKey | None:
|
||||||
|
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
|
||||||
|
try:
|
||||||
|
keys = agent_list_keys()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
for key in keys:
|
||||||
|
if key.comment == "(none)":
|
||||||
|
continue
|
||||||
|
if selector is None or selector in key.comment:
|
||||||
|
return key
|
||||||
|
return None
|
||||||
|
|
||||||
|
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
|
||||||
|
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
|
||||||
|
msg = (
|
||||||
|
bytes([SSH_AGENTC_SIGN_REQUEST])
|
||||||
|
+ pack_ssh_string(key.blob)
|
||||||
|
+ pack_ssh_string(data)
|
||||||
|
+ struct.pack(">I", 0)
|
||||||
|
)
|
||||||
|
response = agent_roundtrip(msg)
|
||||||
|
if response[0] != SSH_AGENT_SIGN_RESPONSE:
|
||||||
|
raise RuntimeError(f"SSH agent refused to sign (response code {response[0]})")
|
||||||
|
sig_blob, _ = unpack_ssh_string(response, 1)
|
||||||
|
_algo, sig_offset = unpack_ssh_string(sig_blob, 0)
|
||||||
|
raw_sig, _ = unpack_ssh_string(sig_blob, sig_offset)
|
||||||
|
if len(raw_sig) != 64:
|
||||||
|
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
|
||||||
|
return raw_sig
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""File-based Ed25519 keys and authorized_keys helpers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from cryptography.hazmat.primitives.serialization import (
|
||||||
|
Encoding,
|
||||||
|
NoEncryption,
|
||||||
|
PrivateFormat,
|
||||||
|
PublicFormat,
|
||||||
|
load_pem_private_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
from browser_cli.auth.agent import AgentKey
|
||||||
|
|
||||||
|
def generate_keypair() -> tuple[bytes, str]:
|
||||||
|
"""Return (private_key_pem_bytes, public_key_hex)."""
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
||||||
|
public_hex = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||||
|
return pem, public_hex
|
||||||
|
|
||||||
|
def load_private_key(path: Path) -> Ed25519PrivateKey:
|
||||||
|
return load_pem_private_key(path.read_bytes(), password=None)
|
||||||
|
|
||||||
|
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
|
||||||
|
if isinstance(key, AgentKey):
|
||||||
|
return key.pubkey_bytes.hex()
|
||||||
|
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||||
|
|
||||||
|
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
|
||||||
|
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
parts = line.split(None, 1)
|
||||||
|
pubkey = parts[0]
|
||||||
|
name = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
result.append((pubkey, name))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def load_authorized_keys(path: Path) -> list[str]:
|
||||||
|
return [pubkey for pubkey, _name in load_authorized_keys_with_names(path)]
|
||||||
|
|
||||||
|
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
|
||||||
|
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing = {pubkey for pubkey, _name in load_authorized_keys_with_names(path)}
|
||||||
|
if pub_hex in existing:
|
||||||
|
return False
|
||||||
|
line = (f"{pub_hex} {name}".rstrip()) + "\n"
|
||||||
|
with open(path, "a", encoding="utf-8") as file:
|
||||||
|
file.write(line)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Post-quantum ML-KEM key exchange and app-layer transport encryption."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
from browser_cli.constants import PQ_TRANSPORT_ALG
|
||||||
|
|
||||||
|
def pq_kex_server_keypair():
|
||||||
|
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
|
||||||
|
|
||||||
|
Returns ``None`` when the installed cryptography/OpenSSL backend does not
|
||||||
|
support ML-KEM yet. The serve/client protocol treats this as graceful
|
||||||
|
downgrade instead of breaking local installs on older OpenSSL builds.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import mlkem
|
||||||
|
private_key = mlkem.MLKEM768PrivateKey.generate()
|
||||||
|
public_key = private_key.public_key().public_bytes_raw()
|
||||||
|
return private_key, public_key
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
|
||||||
|
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import mlkem
|
||||||
|
public_key = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
|
||||||
|
shared_secret, ciphertext = public_key.encapsulate()
|
||||||
|
return ciphertext.hex(), shared_secret
|
||||||
|
|
||||||
|
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
|
||||||
|
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
|
||||||
|
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
|
||||||
|
|
||||||
|
def pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
|
||||||
|
return HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=None,
|
||||||
|
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
|
||||||
|
).derive(shared_secret)
|
||||||
|
|
||||||
|
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
|
||||||
|
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
key = pq_transport_key(shared_secret, direction)
|
||||||
|
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
|
||||||
|
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
|
||||||
|
|
||||||
|
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
|
||||||
|
"""Decrypt an app-layer frame produced by pq_encrypt()."""
|
||||||
|
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
|
||||||
|
raise ValueError("unsupported encrypted transport envelope")
|
||||||
|
key = pq_transport_key(shared_secret, direction)
|
||||||
|
return ChaCha20Poly1305(key).decrypt(
|
||||||
|
bytes.fromhex(str(envelope["nonce"])),
|
||||||
|
bytes.fromhex(str(envelope["ciphertext"])),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def new_nonce() -> str:
|
||||||
|
return secrets.token_hex(32)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Canonical browser-cli auth payload signing and verification."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
|
from browser_cli.auth.agent import AgentKey, agent_sign_raw
|
||||||
|
|
||||||
|
def canonical_payload(msg: dict) -> bytes:
|
||||||
|
"""Deterministic JSON encoding of msg without auth protocol fields."""
|
||||||
|
return json.dumps(
|
||||||
|
{key: value for key, value in msg.items() if key not in {"pubkey", "sig", "pq_kex"}},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
def auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
||||||
|
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
|
||||||
|
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
||||||
|
if pq_shared_secret is not None:
|
||||||
|
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
|
||||||
|
return data
|
||||||
|
|
||||||
|
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
||||||
|
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
|
||||||
|
data = auth_message(nonce, msg, pq_shared_secret)
|
||||||
|
if isinstance(key, AgentKey):
|
||||||
|
return agent_sign_raw(key, data)
|
||||||
|
return key.sign(data)
|
||||||
|
|
||||||
|
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
|
||||||
|
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
|
||||||
|
try:
|
||||||
|
pub_bytes = bytes.fromhex(pub_hex)
|
||||||
|
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
|
||||||
|
pub_key.verify(bytes.fromhex(sig_hex), auth_message(nonce, msg, pq_shared_secret))
|
||||||
|
return True
|
||||||
|
except (InvalidSignature, ValueError):
|
||||||
|
return False
|
||||||
@@ -20,15 +20,22 @@ from browser_cli.commands.session import session_group
|
|||||||
from browser_cli.commands.search import search_group
|
from browser_cli.commands.search import search_group
|
||||||
from browser_cli.commands.page import page_group
|
from browser_cli.commands.page import page_group
|
||||||
from browser_cli.commands.storage import storage_group
|
from browser_cli.commands.storage import storage_group
|
||||||
from browser_cli.commands.cookies import cookies_group
|
|
||||||
from browser_cli.commands.perf import perf_group
|
from browser_cli.commands.perf import perf_group
|
||||||
from browser_cli.commands.extension import extension_group
|
from browser_cli.commands.extension import extension_group
|
||||||
from browser_cli.commands.serve import cmd_serve
|
from browser_cli.commands.serve import cmd_serve
|
||||||
from browser_cli.commands.link_serve import cmd_link_serve
|
|
||||||
from browser_cli.commands.auth import auth_group
|
from browser_cli.commands.auth import auth_group
|
||||||
from browser_cli.commands.clients import clients_group
|
from browser_cli.commands.clients import clients_group
|
||||||
from browser_cli.commands.completion import cmd_completion
|
from browser_cli.commands.completion import cmd_completion
|
||||||
from browser_cli.commands.install import cmd_install
|
from browser_cli.commands.install import cmd_install
|
||||||
|
from browser_cli.commands.doctor import cmd_doctor
|
||||||
|
from browser_cli.commands.events import cmd_events
|
||||||
|
from browser_cli.commands.remote import remote_group
|
||||||
|
from browser_cli.commands.script import cmd_script
|
||||||
|
from browser_cli.commands.serve_http import cmd_serve_http
|
||||||
|
from browser_cli.commands.watch import watch_group
|
||||||
|
from browser_cli.commands.workspace import workspace_group
|
||||||
|
from browser_cli.commands.raw import cmd_command
|
||||||
|
from browser_cli.constants import PYPI_PACKAGE_NAME
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -57,7 +64,7 @@ def _project_version() -> str:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return package_version("browser-cli")
|
return package_version(PYPI_PACKAGE_NAME)
|
||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
@@ -118,14 +125,20 @@ main.add_command(session_group)
|
|||||||
main.add_command(search_group)
|
main.add_command(search_group)
|
||||||
main.add_command(page_group)
|
main.add_command(page_group)
|
||||||
main.add_command(storage_group)
|
main.add_command(storage_group)
|
||||||
main.add_command(cookies_group)
|
|
||||||
main.add_command(perf_group)
|
main.add_command(perf_group)
|
||||||
main.add_command(extension_group)
|
main.add_command(extension_group)
|
||||||
main.add_command(cmd_serve)
|
main.add_command(cmd_serve)
|
||||||
main.add_command(cmd_link_serve)
|
|
||||||
main.add_command(clients_group)
|
main.add_command(clients_group)
|
||||||
main.add_command(cmd_completion)
|
main.add_command(cmd_completion)
|
||||||
main.add_command(cmd_install)
|
main.add_command(cmd_install)
|
||||||
|
main.add_command(cmd_doctor)
|
||||||
|
main.add_command(cmd_events)
|
||||||
|
main.add_command(remote_group)
|
||||||
|
main.add_command(cmd_script)
|
||||||
|
main.add_command(cmd_serve_http)
|
||||||
|
main.add_command(watch_group)
|
||||||
|
main.add_command(workspace_group)
|
||||||
|
main.add_command(cmd_command)
|
||||||
|
|
||||||
# ── native-host (hidden, called by Chrome via native messaging) ────────────────
|
# ── native-host (hidden, called by Chrome via native messaging) ────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from browser_cli.client.core import (
|
|||||||
remote_browser_targets,
|
remote_browser_targets,
|
||||||
remote_browser_targets_async,
|
remote_browser_targets_async,
|
||||||
remote_target_for_alias,
|
remote_target_for_alias,
|
||||||
|
remote_targets_for_alias,
|
||||||
send_command,
|
send_command,
|
||||||
send_command_async,
|
send_command_async,
|
||||||
)
|
)
|
||||||
@@ -42,6 +43,7 @@ __all__ = [
|
|||||||
"remote_browser_targets",
|
"remote_browser_targets",
|
||||||
"remote_browser_targets_async",
|
"remote_browser_targets_async",
|
||||||
"remote_target_for_alias",
|
"remote_target_for_alias",
|
||||||
|
"remote_targets_for_alias",
|
||||||
"send_command",
|
"send_command",
|
||||||
"send_command_async",
|
"send_command_async",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
|
|||||||
for item in items or []:
|
for item in items or []:
|
||||||
profile = str(item.get("profile") or "default")
|
profile = str(item.get("profile") or "default")
|
||||||
display = str(item.get("displayName") or profile)
|
display = str(item.get("displayName") or profile)
|
||||||
|
display_name = _remote_display_name(endpoint, profile, display)
|
||||||
|
browser_name = item.get("browserName") or item.get("name")
|
||||||
targets.append(
|
targets.append(
|
||||||
BrowserTarget(
|
BrowserTarget(
|
||||||
profile=profile,
|
profile=profile,
|
||||||
display_name=_remote_display_name(endpoint, profile, display),
|
display_name=display_name,
|
||||||
socket_path="",
|
socket_path="",
|
||||||
remote=endpoint,
|
remote=endpoint,
|
||||||
|
browser_name=str(browser_name) if browser_name else None,
|
||||||
|
display_group=display_name.rsplit(":", 1)[0],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return targets
|
return targets
|
||||||
@@ -52,15 +56,21 @@ def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> l
|
|||||||
continue
|
continue
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
|
||||||
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
|
"""Return remote targets matching a user-facing alias.
|
||||||
|
|
||||||
|
Exact browser aliases such as ``host:profile`` return one target. Endpoint
|
||||||
|
aliases such as ``host`` or ``host:8765`` may return multiple targets, which
|
||||||
|
lets read/list SDK commands fan out while command dispatch can still reject
|
||||||
|
the ambiguous target.
|
||||||
|
"""
|
||||||
if not alias:
|
if not alias:
|
||||||
return None
|
return []
|
||||||
targets = _remote_browser_targets()
|
targets = _remote_browser_targets(key=key) if key is not None else _remote_browser_targets()
|
||||||
for target in targets:
|
for target in targets:
|
||||||
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
|
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
|
||||||
if alias in {target.display_name, endpoint_profile}:
|
if alias in {target.display_name, endpoint_profile}:
|
||||||
return target
|
return [target]
|
||||||
|
|
||||||
endpoint_matches = []
|
endpoint_matches = []
|
||||||
for target in targets:
|
for target in targets:
|
||||||
@@ -69,16 +79,21 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
|||||||
remote_host, sep, _remote_port = target.remote.rpartition(":")
|
remote_host, sep, _remote_port = target.remote.rpartition(":")
|
||||||
if alias == target.remote or (sep and alias == remote_host):
|
if alias == target.remote or (sep and alias == remote_host):
|
||||||
endpoint_matches.append(target)
|
endpoint_matches.append(target)
|
||||||
if len(endpoint_matches) == 1:
|
return endpoint_matches
|
||||||
return endpoint_matches[0]
|
|
||||||
if len(endpoint_matches) > 1:
|
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||||
aliases = [target.profile for target in endpoint_matches]
|
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
|
||||||
endpoint = endpoint_matches[0].remote or alias
|
matches = remote_targets_for_alias(alias)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
if len(matches) > 1:
|
||||||
|
aliases = [target.profile for target in matches]
|
||||||
|
endpoint = matches[0].remote or alias or "remote"
|
||||||
examples = "\n".join(
|
examples = "\n".join(
|
||||||
f" browser-cli --remote {endpoint} --browser {a} ..."
|
f" browser-cli --remote {endpoint} --browser {a} ..."
|
||||||
for a in aliases
|
for a in aliases
|
||||||
)
|
)
|
||||||
display_aliases = [target.display_name for target in endpoint_matches]
|
display_aliases = [target.display_name for target in matches]
|
||||||
shorthand_examples = "\n".join(
|
shorthand_examples = "\n".join(
|
||||||
f" browser-cli --browser {a} ..."
|
f" browser-cli --browser {a} ..."
|
||||||
for a in display_aliases
|
for a in display_aliases
|
||||||
@@ -137,10 +152,10 @@ def send_command(
|
|||||||
response = (
|
response = (
|
||||||
_send_remote(remote_endpoint, msg, private_key)
|
_send_remote(remote_endpoint, msg, private_key)
|
||||||
if remote_endpoint
|
if remote_endpoint
|
||||||
else local_transport.send_local_sync(profile, payload, target_discovery.resolve_socket)
|
else local_transport.send_local_sync(requested_profile, payload, target_discovery.resolve_socket)
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||||
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(profile)
|
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile)
|
||||||
|
|
||||||
return messages.decode_response(response)
|
return messages.decode_response(response)
|
||||||
|
|
||||||
@@ -192,9 +207,9 @@ async def send_command_async(
|
|||||||
response = (
|
response = (
|
||||||
await _send_remote_async(remote_endpoint, msg, private_key)
|
await _send_remote_async(remote_endpoint, msg, private_key)
|
||||||
if remote_endpoint
|
if remote_endpoint
|
||||||
else await local_transport.send_local_async(profile, payload, target_discovery.resolve_socket)
|
else await local_transport.send_local_async(requested_profile, payload, target_discovery.resolve_socket)
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||||
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(profile)
|
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile)
|
||||||
|
|
||||||
return messages.decode_response(response)
|
return messages.decode_response(response)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class BrowserTarget:
|
|||||||
display_name: str
|
display_name: str
|
||||||
socket_path: str
|
socket_path: str
|
||||||
remote: str | None = None
|
remote: str | None = None
|
||||||
|
browser_name: str | None = None
|
||||||
|
display_group: str | None = None
|
||||||
|
|
||||||
def is_reachable_unix_endpoint(endpoint: str) -> bool:
|
def is_reachable_unix_endpoint(endpoint: str) -> bool:
|
||||||
"""Return True when a Unix socket path exists and accepts connections."""
|
"""Return True when a Unix socket path exists and accepts connections."""
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Safety policy for generic command execution surfaces.
|
||||||
|
|
||||||
|
Dedicated first-party CLI/SDK methods keep their normal behavior. This module
|
||||||
|
only gates raw surfaces where a single string can trigger arbitrary browser
|
||||||
|
capabilities: ``browser-cli command``, ``browser-cli script``, and the HTTP
|
||||||
|
``/command`` endpoint.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
SAFE_COMMANDS = {
|
||||||
|
"clients.list",
|
||||||
|
"extension.capabilities",
|
||||||
|
"extension.info",
|
||||||
|
"group.list",
|
||||||
|
"group.query",
|
||||||
|
"group.tabs",
|
||||||
|
"page.info",
|
||||||
|
"perf.status",
|
||||||
|
"tabs.active_in_window",
|
||||||
|
"tabs.count",
|
||||||
|
"tabs.filter",
|
||||||
|
"tabs.list",
|
||||||
|
"tabs.query",
|
||||||
|
"tabs.status",
|
||||||
|
"windows.list",
|
||||||
|
}
|
||||||
|
|
||||||
|
READ_PAGE_COMMANDS = {
|
||||||
|
"dom.attr",
|
||||||
|
"dom.exists",
|
||||||
|
"dom.query",
|
||||||
|
"dom.text",
|
||||||
|
"extract.html",
|
||||||
|
"extract.images",
|
||||||
|
"extract.json",
|
||||||
|
"extract.links",
|
||||||
|
"extract.markdown",
|
||||||
|
"extract.text",
|
||||||
|
"tabs.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTROL_PREFIXES = (
|
||||||
|
"navigate.",
|
||||||
|
"nav.",
|
||||||
|
"group.",
|
||||||
|
"session.",
|
||||||
|
"tabs.",
|
||||||
|
"windows.",
|
||||||
|
)
|
||||||
|
CONTROL_COMMANDS = {
|
||||||
|
"dom.check",
|
||||||
|
"dom.clear",
|
||||||
|
"dom.click",
|
||||||
|
"dom.focus",
|
||||||
|
"dom.hover",
|
||||||
|
"dom.key",
|
||||||
|
"dom.poll",
|
||||||
|
"dom.scroll",
|
||||||
|
"dom.select",
|
||||||
|
"dom.submit",
|
||||||
|
"dom.type",
|
||||||
|
"dom.uncheck",
|
||||||
|
"dom.wait_for",
|
||||||
|
"extension.reload",
|
||||||
|
}
|
||||||
|
|
||||||
|
DANGEROUS_COMMANDS = {
|
||||||
|
"dom.eval",
|
||||||
|
"tabs.screenshot",
|
||||||
|
}
|
||||||
|
DANGEROUS_PREFIXES = (
|
||||||
|
"storage.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandPolicy:
|
||||||
|
allow_read_page: bool = False
|
||||||
|
allow_control: bool = False
|
||||||
|
allow_dangerous: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unrestricted(cls) -> "CommandPolicy":
|
||||||
|
return cls(allow_read_page=True, allow_control=True, allow_dangerous=True)
|
||||||
|
|
||||||
|
def _is_control(command: str) -> bool:
|
||||||
|
if command in CONTROL_COMMANDS:
|
||||||
|
return True
|
||||||
|
if any(command.startswith(prefix) for prefix in CONTROL_PREFIXES):
|
||||||
|
return command not in SAFE_COMMANDS and command not in READ_PAGE_COMMANDS and command not in DANGEROUS_COMMANDS
|
||||||
|
return False
|
||||||
|
|
||||||
|
def command_category(command: str) -> str:
|
||||||
|
name = str(command or "")
|
||||||
|
if name in DANGEROUS_COMMANDS or any(name.startswith(prefix) for prefix in DANGEROUS_PREFIXES):
|
||||||
|
return "dangerous"
|
||||||
|
if name in READ_PAGE_COMMANDS:
|
||||||
|
return "read-page"
|
||||||
|
if name in SAFE_COMMANDS:
|
||||||
|
return "safe"
|
||||||
|
if _is_control(name):
|
||||||
|
return "control"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def assert_command_allowed(command: str, policy: CommandPolicy) -> None:
|
||||||
|
category = command_category(command)
|
||||||
|
if category == "safe":
|
||||||
|
return
|
||||||
|
if category == "read-page" and policy.allow_read_page:
|
||||||
|
return
|
||||||
|
if category == "control" and policy.allow_control:
|
||||||
|
return
|
||||||
|
if category == "dangerous" and policy.allow_dangerous:
|
||||||
|
return
|
||||||
|
raise PermissionError(
|
||||||
|
f"Raw command '{command}' is {category} and blocked by default; "
|
||||||
|
"use --allow-read-page, --allow-control, or --allow-dangerous explicitly"
|
||||||
|
)
|
||||||
@@ -71,6 +71,9 @@ def handle_errors(fn):
|
|||||||
except BrowserNotConnected as e:
|
except BrowserNotConnected as e:
|
||||||
_console.print(f"[red]Error:[/red] {e}")
|
_console.print(f"[red]Error:[/red] {e}")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
except PermissionError as e:
|
||||||
|
_console.print(f"[red]Blocked:[/red] {e}")
|
||||||
|
raise SystemExit(1)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
_console.print(f"[red]Browser error:[/red] {e}")
|
_console.print(f"[red]Browser error:[/red] {e}")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|||||||
@@ -159,11 +159,14 @@ def _print_clients(all_clients: list) -> None:
|
|||||||
help="Browser profile alias to rename. Overrides the global --browser option for this command.",
|
help="Browser profile alias to rename. Overrides the global --browser option for this command.",
|
||||||
)
|
)
|
||||||
@click.argument("alias")
|
@click.argument("alias")
|
||||||
def cmd_clients_rename(target_browser, alias):
|
@click.pass_context
|
||||||
|
def cmd_clients_rename(ctx, target_browser, alias):
|
||||||
"""Set the profile alias used to identify this browser instance."""
|
"""Set the profile alias used to identify this browser instance."""
|
||||||
|
root_obj = ctx.find_root().obj or {}
|
||||||
|
selected_browser = target_browser or root_obj.get("browser")
|
||||||
try:
|
try:
|
||||||
_ensure_unique_browser_alias(alias, target_browser)
|
_ensure_unique_browser_alias(alias, selected_browser)
|
||||||
send_command("clients.rename_profile", {"alias": alias}, profile=target_browser)
|
send_command("clients.rename_profile", {"alias": alias}, profile=selected_browser)
|
||||||
except BrowserNotConnected as e:
|
except BrowserNotConnected as e:
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import click
|
|
||||||
from browser_cli.commands import client_from_ctx, handle_errors
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
@click.group("cookies")
|
|
||||||
def cookies_group():
|
|
||||||
"""Manage browser cookies."""
|
|
||||||
|
|
||||||
@cookies_group.command("list")
|
|
||||||
@click.option("--url", default=None, help="Filter by URL")
|
|
||||||
@click.option("--domain", default=None, help="Filter by domain")
|
|
||||||
@click.option("--name", default=None, help="Filter by cookie name")
|
|
||||||
@handle_errors
|
|
||||||
def cookies_list(url, domain, name):
|
|
||||||
"""List cookies, optionally filtered by URL, domain, or name."""
|
|
||||||
cookies = client_from_ctx().cookies.list(url=url, domain=domain, name=name)
|
|
||||||
if not cookies:
|
|
||||||
console.print("[yellow]No cookies found[/yellow]")
|
|
||||||
return
|
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
|
||||||
table.add_column("Name")
|
|
||||||
table.add_column("Value")
|
|
||||||
table.add_column("Domain")
|
|
||||||
table.add_column("Path")
|
|
||||||
table.add_column("Secure", width=7)
|
|
||||||
table.add_column("HttpOnly", width=9)
|
|
||||||
for c in cookies:
|
|
||||||
table.add_row(
|
|
||||||
c.get("name", ""),
|
|
||||||
(c.get("value") or "")[:60],
|
|
||||||
c.get("domain", ""),
|
|
||||||
c.get("path", ""),
|
|
||||||
"[green]✓[/green]" if c.get("secure") else "",
|
|
||||||
"[green]✓[/green]" if c.get("httpOnly") else "",
|
|
||||||
)
|
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
@cookies_group.command("get")
|
|
||||||
@click.argument("url")
|
|
||||||
@click.argument("name")
|
|
||||||
@handle_errors
|
|
||||||
def cookies_get(url, name):
|
|
||||||
"""Get the value of a single cookie by URL and NAME."""
|
|
||||||
cookie = client_from_ctx().cookies.get(url, name)
|
|
||||||
if cookie is None:
|
|
||||||
console.print(f"[yellow]Cookie '{name}' not found for {url}[/yellow]")
|
|
||||||
raise SystemExit(1)
|
|
||||||
console.print(cookie.get("value", ""))
|
|
||||||
|
|
||||||
@cookies_group.command("set")
|
|
||||||
@click.argument("url")
|
|
||||||
@click.argument("name")
|
|
||||||
@click.argument("value")
|
|
||||||
@click.option("--domain", default=None)
|
|
||||||
@click.option("--path", default=None)
|
|
||||||
@click.option("--secure", is_flag=True)
|
|
||||||
@click.option("--http-only", "http_only", is_flag=True)
|
|
||||||
@click.option("--expires", "expiration_date", type=float, default=None, help="Unix timestamp")
|
|
||||||
@click.option("--same-site", type=click.Choice(["no_restriction", "lax", "strict"]), default=None)
|
|
||||||
@handle_errors
|
|
||||||
def cookies_set(url, name, value, domain, path, secure, http_only, expiration_date, same_site):
|
|
||||||
"""Set a cookie on URL."""
|
|
||||||
client_from_ctx().cookies.set(
|
|
||||||
url, name, value,
|
|
||||||
domain=domain, path=path,
|
|
||||||
secure=secure or None, http_only=http_only or None,
|
|
||||||
expiration_date=expiration_date, same_site=same_site,
|
|
||||||
)
|
|
||||||
console.print(f"[green]Set cookie:[/green] {name}={value!r} on {url}")
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from browser_cli.commands import handle_errors, client_from_ctx
|
||||||
|
from browser_cli.client import active_browser_targets
|
||||||
|
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME, PYPI_PACKAGE_NAME
|
||||||
|
from browser_cli.platform import is_windows
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
def _project_version() -> str:
|
||||||
|
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||||
|
try:
|
||||||
|
content = pyproject_path.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return package_version(PYPI_PACKAGE_NAME)
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def _status(ok: bool) -> str:
|
||||||
|
return "[green]OK[/green]" if ok else "[red]FAIL[/red]"
|
||||||
|
|
||||||
|
@click.command("doctor")
|
||||||
|
@click.option("--remote", "check_remote", is_flag=True, help="Also probe the configured remote endpoint")
|
||||||
|
@handle_errors
|
||||||
|
def cmd_doctor(check_remote):
|
||||||
|
"""Diagnose browser-cli installation, extension, and connection health."""
|
||||||
|
rows: list[tuple[str, bool, str]] = []
|
||||||
|
version = _project_version()
|
||||||
|
rows.append(("Python package", version != "unknown", version))
|
||||||
|
rows.append(("browser-cli executable", shutil.which("browser-cli") is not None, shutil.which("browser-cli") or "not on PATH"))
|
||||||
|
|
||||||
|
manifest_notes = []
|
||||||
|
if not is_windows():
|
||||||
|
import sys
|
||||||
|
platform = "darwin" if sys.platform == "darwin" else "linux"
|
||||||
|
for browser, by_platform in NATIVE_HOST_DIRS.items():
|
||||||
|
for directory in by_platform.get(platform, []):
|
||||||
|
path = directory / f"{NATIVE_HOST_NAME}.json"
|
||||||
|
if path.exists():
|
||||||
|
manifest_notes.append(f"{browser}: {path}")
|
||||||
|
rows.append(("Native host manifest", bool(manifest_notes), "; ".join(manifest_notes) or "not found for common browsers"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
targets = active_browser_targets(include_remotes=check_remote)
|
||||||
|
rows.append(("Browser registry", bool(targets), f"{len(targets)} active target(s)"))
|
||||||
|
except Exception as exc:
|
||||||
|
rows.append(("Browser registry", False, str(exc)))
|
||||||
|
|
||||||
|
client = client_from_ctx()
|
||||||
|
try:
|
||||||
|
clients = client.clients()
|
||||||
|
rows.append(("Connection", True, f"{len(clients)} client(s) responded"))
|
||||||
|
ext_versions = sorted({str(c.get("extensionVersion", "unknown")) for c in clients if isinstance(c, dict)})
|
||||||
|
if ext_versions:
|
||||||
|
rows.append(("Extension version", version in ext_versions, ", ".join(ext_versions)))
|
||||||
|
except Exception as exc:
|
||||||
|
rows.append(("Connection", False, str(exc)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = client.extension.info()
|
||||||
|
caps = info.get("capabilities") or []
|
||||||
|
rows.append(("Extension info", True, f"v{info.get('version', 'unknown')} · {len(caps)} capabilities"))
|
||||||
|
except Exception as exc:
|
||||||
|
rows.append(("Extension info", False, f"not available ({exc})"))
|
||||||
|
|
||||||
|
table = Table(show_header=True, header_style="bold cyan")
|
||||||
|
table.add_column("Check")
|
||||||
|
table.add_column("Status")
|
||||||
|
table.add_column("Details")
|
||||||
|
for name, ok, detail in rows:
|
||||||
|
table.add_row(name, _status(ok), detail)
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
failed = [name for name, ok, _ in rows if not ok and name in {"Connection", "Browser registry"}]
|
||||||
|
if failed:
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from browser_cli.commands import client_from_ctx, handle_errors
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
def _snapshot(client):
|
||||||
|
tabs = client.tabs.list()
|
||||||
|
return {str(t.id): asdict(t) if is_dataclass(t) else dict(t) for t in tabs}
|
||||||
|
|
||||||
|
def _emit(event, json_output: bool):
|
||||||
|
if json_output:
|
||||||
|
click.echo(json.dumps(event, default=str), flush=True)
|
||||||
|
else:
|
||||||
|
kind = event.get("type")
|
||||||
|
tab = event.get("tab") or {}
|
||||||
|
console.print(f"[cyan]{kind}[/cyan] {tab.get('id', '')} {tab.get('title') or ''} [dim]{tab.get('url') or ''}[/dim]")
|
||||||
|
|
||||||
|
@click.command("events")
|
||||||
|
@click.option("--interval", type=float, default=1.0, show_default=True, help="Polling interval in seconds")
|
||||||
|
@click.option("--once", is_flag=True, help="Emit initial snapshot and exit")
|
||||||
|
@click.option("--json", "json_output", is_flag=True, default=True, help="Emit JSON Lines (default)")
|
||||||
|
@click.option("--pretty", is_flag=True, help="Render human-readable events instead of JSON")
|
||||||
|
@handle_errors
|
||||||
|
def cmd_events(interval: float, once: bool, json_output: bool, pretty: bool):
|
||||||
|
"""Stream tab events as JSON Lines using a lightweight polling watcher."""
|
||||||
|
json_output = json_output and not pretty
|
||||||
|
client = client_from_ctx()
|
||||||
|
previous = _snapshot(client)
|
||||||
|
for tab in previous.values():
|
||||||
|
_emit({"type": "tabs.snapshot", "tab": tab}, json_output)
|
||||||
|
if once:
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
time.sleep(interval)
|
||||||
|
current = _snapshot(client)
|
||||||
|
for tab_id, tab in current.items():
|
||||||
|
if tab_id not in previous:
|
||||||
|
_emit({"type": "tabs.created", "tab": tab}, json_output)
|
||||||
|
elif tab != previous[tab_id]:
|
||||||
|
_emit({"type": "tabs.updated", "tab": tab, "previous": previous[tab_id]}, json_output)
|
||||||
|
for tab_id, tab in previous.items():
|
||||||
|
if tab_id not in current:
|
||||||
|
_emit({"type": "tabs.closed", "tab": tab}, json_output)
|
||||||
|
previous = current
|
||||||
@@ -8,6 +8,27 @@ console = Console()
|
|||||||
def extension_group():
|
def extension_group():
|
||||||
"""Manage the browser-cli browser extension."""
|
"""Manage the browser-cli browser extension."""
|
||||||
|
|
||||||
|
@extension_group.command("info")
|
||||||
|
@handle_errors
|
||||||
|
def extension_info():
|
||||||
|
"""Show extension version and advertised capabilities."""
|
||||||
|
info = client_from_ctx().extension.info()
|
||||||
|
for key in ("name", "version", "id", "platform"):
|
||||||
|
if key in info:
|
||||||
|
console.print(f"[bold]{key}:[/bold] {info[key]}")
|
||||||
|
caps = info.get("capabilities") or []
|
||||||
|
if caps:
|
||||||
|
console.print("[bold]capabilities:[/bold]")
|
||||||
|
for cap in caps:
|
||||||
|
console.print(f" - {cap}")
|
||||||
|
|
||||||
|
@extension_group.command("capabilities")
|
||||||
|
@handle_errors
|
||||||
|
def extension_capabilities():
|
||||||
|
"""Print extension feature capability strings."""
|
||||||
|
for cap in client_from_ctx().extension.capabilities():
|
||||||
|
console.print(cap)
|
||||||
|
|
||||||
@extension_group.command("reload")
|
@extension_group.command("reload")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def extension_reload():
|
def extension_reload():
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import click
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from browser_cli.constants import (
|
from browser_cli.constants import (
|
||||||
|
ALLOWED_EXTENSION_IDS,
|
||||||
EXTENSION_ID,
|
EXTENSION_ID,
|
||||||
|
FIREFOX_EXTENSION_ID,
|
||||||
NATIVE_HOST_DIRS,
|
NATIVE_HOST_DIRS,
|
||||||
NATIVE_HOST_NAME,
|
NATIVE_HOST_NAME,
|
||||||
SUPPORTED_BROWSERS,
|
SUPPORTED_BROWSERS,
|
||||||
|
WEBSTORE_EXTENSION_ID,
|
||||||
WINDOWS_NATIVE_HOST_REGISTRY_KEYS,
|
WINDOWS_NATIVE_HOST_REGISTRY_KEYS,
|
||||||
)
|
)
|
||||||
from browser_cli.platform import install_base_dir, is_windows
|
from browser_cli.platform import install_base_dir, is_windows
|
||||||
@@ -70,20 +73,27 @@ def cmd_install(browser):
|
|||||||
"brave": "brave://extensions",
|
"brave": "brave://extensions",
|
||||||
"edge": "edge://extensions",
|
"edge": "edge://extensions",
|
||||||
"vivaldi": "vivaldi://extensions",
|
"vivaldi": "vivaldi://extensions",
|
||||||
|
"firefox": "about:debugging#/runtime/this-firefox",
|
||||||
}[browser]
|
}[browser]
|
||||||
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
|
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
|
||||||
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
||||||
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
|
if browser == "firefox":
|
||||||
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
|
repo_root = Path(__file__).parent.parent.parent
|
||||||
console.print(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
|
firefox_manifest = repo_root / "dist" / "extension-package-firefox" / "manifest.json"
|
||||||
|
console.print(" 2. Build the Firefox-compatible temporary extension:")
|
||||||
|
console.print(" [cyan]npm run package:extension:firefox[/cyan]")
|
||||||
|
console.print(" 3. Click [bold]Load Temporary Add-on...[/bold]")
|
||||||
|
console.print(f" 4. Select: [cyan]{firefox_manifest}[/cyan]")
|
||||||
|
console.print(" Do not select extension/manifest.json; Firefox currently rejects background.service_worker there.")
|
||||||
|
console.print(f" 5. Firefox extension ID is [cyan]{FIREFOX_EXTENSION_ID}[/cyan]")
|
||||||
|
console.print(" Note: Firefox support is experimental; tab-group commands require browser tab group APIs.\n")
|
||||||
|
else:
|
||||||
|
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
|
||||||
|
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
|
||||||
|
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
|
||||||
|
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
|
||||||
|
|
||||||
manifest = {
|
manifest = _native_host_manifest(browser, host_exe)
|
||||||
"name": NATIVE_HOST_NAME,
|
|
||||||
"description": "browser-cli native messaging host",
|
|
||||||
"path": str(host_exe),
|
|
||||||
"type": "stdio",
|
|
||||||
"allowed_origins": [f"chrome-extension://{EXTENSION_ID}/"],
|
|
||||||
}
|
|
||||||
installed = _install_manifest(browser, host_exe, manifest)
|
installed = _install_manifest(browser, host_exe, manifest)
|
||||||
if not installed:
|
if not installed:
|
||||||
console.print("[red]Failed to install native host manifest[/red]")
|
console.print("[red]Failed to install native host manifest[/red]")
|
||||||
@@ -97,6 +107,20 @@ def cmd_install(browser):
|
|||||||
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
||||||
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
||||||
|
|
||||||
|
def _native_host_manifest(browser: str, host_exe: Path) -> dict:
|
||||||
|
base = {
|
||||||
|
"name": NATIVE_HOST_NAME,
|
||||||
|
"description": "browser-cli native messaging host",
|
||||||
|
"path": str(host_exe),
|
||||||
|
"type": "stdio",
|
||||||
|
}
|
||||||
|
if browser == "firefox":
|
||||||
|
return {**base, "allowed_extensions": [FIREFOX_EXTENSION_ID]}
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"allowed_origins": [f"chrome-extension://{extension_id}/" for extension_id in ALLOWED_EXTENSION_IDS],
|
||||||
|
}
|
||||||
|
|
||||||
def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
|
def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
|
||||||
if is_windows():
|
if is_windows():
|
||||||
manifest_dir = host_exe.parent
|
manifest_dir = host_exe.parent
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
"""Expose this browser over a ServiceLink HTTP /rpc endpoint.
|
|
||||||
|
|
||||||
This lets the other nodes (picoshare, website) drive the browser through the
|
|
||||||
shared servicelink envelope, reusing browser-cli's own wire commands verbatim:
|
|
||||||
a link method like `tabs.list` forwards straight to `send_command_async`.
|
|
||||||
|
|
||||||
It is separate from `serve` (the Ed25519/TCP remote-control daemon); use this
|
|
||||||
when you want a node in the mesh to call the browser with a bearer token.
|
|
||||||
|
|
||||||
servicelink (and its httpx dependency) is imported lazily inside the command so
|
|
||||||
that a missing optional dependency never breaks the rest of the CLI.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import hmac
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from browser_cli.client.core import send_command_async
|
|
||||||
from browser_cli.errors import BrowserNotConnected
|
|
||||||
|
|
||||||
# Curated set of browser commands exposed over the mesh. Method name == command.
|
|
||||||
EXPOSED_COMMANDS = [
|
|
||||||
"tabs.list", "tabs.open", "tabs.close", "tabs.active", "tabs.query",
|
|
||||||
"nav.open", "nav.reload", "nav.back", "nav.forward",
|
|
||||||
"dom.query", "dom.text", "dom.attr", "dom.exists", "dom.click",
|
|
||||||
"extract.links", "extract.images", "extract.text", "extract.markdown", "extract.html",
|
|
||||||
"page.info",
|
|
||||||
"session.save", "session.load", "session.list",
|
|
||||||
]
|
|
||||||
|
|
||||||
def _import_servicelink():
|
|
||||||
"""Import servicelink lazily; the flat submodule sits at the repo root."""
|
|
||||||
repo_root = Path(__file__).resolve().parents[2]
|
|
||||||
if str(repo_root) not in sys.path:
|
|
||||||
sys.path.insert(0, str(repo_root))
|
|
||||||
try:
|
|
||||||
import servicelink
|
|
||||||
return servicelink
|
|
||||||
except ImportError as exc:
|
|
||||||
raise click.ClickException(
|
|
||||||
"servicelink could not be imported. Initialise the submodule "
|
|
||||||
"(`git submodule update --init`) and ensure httpx is installed."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def _build_router(sl, profile):
|
|
||||||
router = sl.Router("browser-cli")
|
|
||||||
|
|
||||||
def make_handler(command):
|
|
||||||
async def handler(params, ctx):
|
|
||||||
try:
|
|
||||||
return await send_command_async(command, params or None, profile=profile)
|
|
||||||
except BrowserNotConnected as exc:
|
|
||||||
raise sl.Unavailable(f"browser not connected: {exc}")
|
|
||||||
except (RuntimeError, ConnectionError) as exc:
|
|
||||||
raise sl.LinkError(str(exc))
|
|
||||||
return handler
|
|
||||||
|
|
||||||
for command in EXPOSED_COMMANDS:
|
|
||||||
router.register(command, make_handler(command))
|
|
||||||
return router
|
|
||||||
|
|
||||||
def _make_verifier(sl, token):
|
|
||||||
if not token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def verify(authorization, request):
|
|
||||||
presented = (authorization or "").split(" ", 1)[-1].strip()
|
|
||||||
# Constant-time compare so a wrong token can't be timed out character by character.
|
|
||||||
if not presented or not hmac.compare_digest(presented, token):
|
|
||||||
raise sl.Unauthorized("invalid or missing token")
|
|
||||||
return sl.Principal(subject="mesh", scopes=frozenset({"all", "mesh"}))
|
|
||||||
|
|
||||||
return verify
|
|
||||||
|
|
||||||
def _http_response(status, body, content_type):
|
|
||||||
head = (
|
|
||||||
f"HTTP/1.1 {status}\r\n"
|
|
||||||
f"Content-Type: {content_type}\r\n"
|
|
||||||
f"Content-Length: {len(body)}\r\n"
|
|
||||||
"Connection: close\r\n\r\n"
|
|
||||||
).encode("latin-1")
|
|
||||||
return head + body
|
|
||||||
|
|
||||||
async def _handle_connection(sl, router, verify, reader, writer):
|
|
||||||
try:
|
|
||||||
request_line = await reader.readline()
|
|
||||||
if not request_line:
|
|
||||||
return
|
|
||||||
headers = {}
|
|
||||||
while True:
|
|
||||||
line = await reader.readline()
|
|
||||||
if line in (b"\r\n", b"\n", b""):
|
|
||||||
break
|
|
||||||
key, _, value = line.decode("latin-1").partition(":")
|
|
||||||
headers[key.strip().lower()] = value.strip()
|
|
||||||
length = int(headers.get("content-length", "0") or "0")
|
|
||||||
body = await reader.readexactly(length) if length else b""
|
|
||||||
|
|
||||||
if not request_line.upper().startswith(b"POST"):
|
|
||||||
writer.write(_http_response(405, b'{"error":"only POST /rpc is supported"}', "application/json"))
|
|
||||||
else:
|
|
||||||
status, payload, content_type = await sl.handle_envelope(
|
|
||||||
router,
|
|
||||||
body,
|
|
||||||
authorization=headers.get("authorization"),
|
|
||||||
verify=verify,
|
|
||||||
content_type=headers.get("content-type", "application/json"),
|
|
||||||
)
|
|
||||||
writer.write(_http_response(status, payload, content_type))
|
|
||||||
await writer.drain()
|
|
||||||
except Exception: # noqa: BLE001 - never let one connection kill the server
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
writer.close()
|
|
||||||
|
|
||||||
async def _serve(sl, host, port, profile, token):
|
|
||||||
router = _build_router(sl, profile)
|
|
||||||
verify = _make_verifier(sl, token)
|
|
||||||
server = await asyncio.start_server(
|
|
||||||
lambda r, w: _handle_connection(sl, router, verify, r, w), host, port
|
|
||||||
)
|
|
||||||
click.echo(f"servicelink browser node listening on http://{host}:{port}/rpc")
|
|
||||||
async with server:
|
|
||||||
await server.serve_forever()
|
|
||||||
|
|
||||||
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
|
|
||||||
|
|
||||||
@click.command("link-serve")
|
|
||||||
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
|
|
||||||
@click.option("--port", default=8770, show_default=True, type=int, help="HTTP port for /rpc.")
|
|
||||||
@click.option("--token", default=None, metavar="SECRET",
|
|
||||||
help="Shared bearer token required from callers (sent as 'Authorization: Bearer ...').")
|
|
||||||
@click.option("--insecure", is_flag=True, default=False,
|
|
||||||
help="Run with NO token. Grants full browser control (cookies, pages) to anyone who can reach the port.")
|
|
||||||
@click.pass_context
|
|
||||||
def cmd_link_serve(ctx, host, port, token, insecure):
|
|
||||||
"""Serve this browser to the ServiceLink mesh over HTTP /rpc.
|
|
||||||
|
|
||||||
Exposes the running browser (open/scrape pages, read cookies and storage), so
|
|
||||||
a token is required by default. Bind to loopback and keep the port off the
|
|
||||||
public network.
|
|
||||||
"""
|
|
||||||
if not token and not insecure:
|
|
||||||
raise click.ClickException(
|
|
||||||
"Refusing to start without --token (this endpoint can control your browser "
|
|
||||||
"and read its cookies). Pass --insecure to override on a trusted host."
|
|
||||||
)
|
|
||||||
if host not in _LOOPBACK_HOSTS:
|
|
||||||
click.echo(
|
|
||||||
f"WARNING: binding to {host} (not loopback). Anyone who can reach this "
|
|
||||||
"address may control the browser; ensure it is firewalled.",
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
if insecure and not token:
|
|
||||||
click.echo("WARNING: --insecure set; the endpoint is UNAUTHENTICATED.", err=True)
|
|
||||||
sl = _import_servicelink()
|
|
||||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
|
||||||
try:
|
|
||||||
asyncio.run(_serve(sl, host, port, profile, token))
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
@@ -10,13 +10,16 @@ def nav_group():
|
|||||||
|
|
||||||
@nav_group.command("open")
|
@nav_group.command("open")
|
||||||
@click.argument("url")
|
@click.argument("url")
|
||||||
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
|
@click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front")
|
||||||
@click.option("--window", "window_name", default=None, help="Open in named window")
|
@click.option("--window", "window_name", default=None, help="Open in named window")
|
||||||
@click.option("--group", "group_name", default=None, help="Open directly into a tab group (name or ID)")
|
@click.option("--group", "group_name", default=None, help="Open directly into a tab group (name or ID)")
|
||||||
|
@click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
|
||||||
|
@click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
|
||||||
|
@click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def cmd_open(url, bg, window_name, group_name):
|
def cmd_open(url, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
|
||||||
"""Open URL in a new tab."""
|
"""Open URL in a new tab without stealing focus by default."""
|
||||||
client_from_ctx().nav.open(url, background=bg, window=window_name, group=group_name)
|
client_from_ctx().nav.open(url, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
||||||
suffix = ""
|
suffix = ""
|
||||||
if group_name:
|
if group_name:
|
||||||
suffix = f" in group '{group_name}'"
|
suffix = f" in group '{group_name}'"
|
||||||
@@ -70,13 +73,16 @@ def cmd_focus(pattern):
|
|||||||
@nav_group.command("open-wait")
|
@nav_group.command("open-wait")
|
||||||
@click.argument("url")
|
@click.argument("url")
|
||||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load")
|
@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("--focus", is_flag=True, help="Bring the opened tab/window to the front")
|
||||||
@click.option("--window", "window_name", default=None, help="Open in named window")
|
@click.option("--window", "window_name", default=None, help="Open in named window")
|
||||||
@click.option("--group", "group_name", default=None, help="Open in tab group")
|
@click.option("--group", "group_name", default=None, help="Open in tab group")
|
||||||
|
@click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
|
||||||
|
@click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
|
||||||
|
@click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def cmd_open_wait(url, timeout, bg, window_name, group_name):
|
def cmd_open_wait(url, timeout, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
|
||||||
"""Open URL in a new tab and wait until fully loaded."""
|
"""Open URL in a new tab and wait until fully loaded."""
|
||||||
tab = client_from_ctx().nav.open_wait(url, timeout=timeout, background=bg, window=window_name, group=group_name)
|
tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
||||||
console.print(f"[green]Loaded:[/green] {url}" + (f" — {tab.title}" if tab.title else ""))
|
console.print(f"[green]Loaded:[/green] {url}" + (f" — {tab.title}" if tab.title else ""))
|
||||||
|
|
||||||
@nav_group.command("wait")
|
@nav_group.command("wait")
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||||
|
from browser_cli.commands import client_from_ctx, handle_errors
|
||||||
|
|
||||||
|
@click.command("command")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.argument("args_json", required=False, default="{}")
|
||||||
|
@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
|
||||||
|
@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
|
||||||
|
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
|
||||||
|
@handle_errors
|
||||||
|
def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous):
|
||||||
|
"""Send a raw browser-cli wire command and print JSON."""
|
||||||
|
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
|
||||||
|
assert_command_allowed(name, policy)
|
||||||
|
args = json.loads(args_json) if args_json else {}
|
||||||
|
result = client_from_ctx().command(name, args)
|
||||||
|
click.echo(json.dumps(result, indent=2, default=str))
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from browser_cli import BrowserCLI
|
||||||
|
from browser_cli.commands import handle_errors
|
||||||
|
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
@click.group("remote")
|
||||||
|
def remote_group():
|
||||||
|
"""Manage remembered browser-cli remote endpoints."""
|
||||||
|
|
||||||
|
@remote_group.command("status")
|
||||||
|
@click.argument("endpoint")
|
||||||
|
@click.option("--key", default=None, help="Key spec/path to use for this probe")
|
||||||
|
@handle_errors
|
||||||
|
def remote_status(endpoint, key):
|
||||||
|
"""Probe a remote endpoint and show server/client status."""
|
||||||
|
client = BrowserCLI(remote=endpoint, key=key)
|
||||||
|
clients = client.clients()
|
||||||
|
table = Table(show_header=True, header_style="bold cyan")
|
||||||
|
table.add_column("Profile")
|
||||||
|
table.add_column("Browser")
|
||||||
|
table.add_column("Extension")
|
||||||
|
for item in clients:
|
||||||
|
table.add_row(str(item.get("profile", "")), str(item.get("name", "")), str(item.get("extensionVersion", "")))
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
@remote_group.command("trust")
|
||||||
|
@click.argument("endpoint")
|
||||||
|
@click.argument("key_spec")
|
||||||
|
def remote_trust(endpoint, key_spec):
|
||||||
|
"""Remember which key spec to use for ENDPOINT."""
|
||||||
|
save_remote_key(endpoint, key_spec)
|
||||||
|
console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
|
||||||
|
|
||||||
|
@remote_group.command("keys")
|
||||||
|
def remote_keys():
|
||||||
|
"""List remembered remote key specs."""
|
||||||
|
remotes = load_remotes()
|
||||||
|
if not remotes:
|
||||||
|
console.print("[yellow]No remembered remotes[/yellow]")
|
||||||
|
return
|
||||||
|
table = Table(show_header=True, header_style="bold cyan")
|
||||||
|
table.add_column("Endpoint")
|
||||||
|
table.add_column("Key")
|
||||||
|
for endpoint, cfg in sorted(remotes.items()):
|
||||||
|
table.add_row(endpoint, str(cfg.get("key", "")))
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
@remote_group.command("revoke")
|
||||||
|
@click.argument("endpoint")
|
||||||
|
def remote_revoke(endpoint):
|
||||||
|
"""Remove remembered key/config for ENDPOINT."""
|
||||||
|
remotes = load_remotes()
|
||||||
|
if endpoint not in remotes:
|
||||||
|
console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
|
||||||
|
return
|
||||||
|
del remotes[endpoint]
|
||||||
|
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
console.print(f"[green]Revoked {endpoint}[/green]")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Reusable rendering helpers for CLI command modules."""
|
||||||
|
from browser_cli.commands.rendering.common import (
|
||||||
|
Column,
|
||||||
|
item_value,
|
||||||
|
print_table_rows,
|
||||||
|
print_tree,
|
||||||
|
shorten,
|
||||||
|
terminal_width,
|
||||||
|
tree_title_limit,
|
||||||
|
tree_url_limit,
|
||||||
|
)
|
||||||
|
from browser_cli.commands.rendering.labels import (
|
||||||
|
BROWSER_FAMILY_STYLES,
|
||||||
|
DEFAULT_BROWSER_STYLE,
|
||||||
|
DEFAULT_SCOPE,
|
||||||
|
browser_label_style,
|
||||||
|
group_tree_label,
|
||||||
|
no_wrap_text,
|
||||||
|
scoped_browser_label,
|
||||||
|
tab_tree_label,
|
||||||
|
)
|
||||||
|
from browser_cli.commands.rendering.tabs_tree import (
|
||||||
|
TabsTreeBuilder,
|
||||||
|
browser_label_key,
|
||||||
|
browser_scope,
|
||||||
|
build_tabs_tree,
|
||||||
|
tab_group_id,
|
||||||
|
tab_sort_key,
|
||||||
|
tab_window_id,
|
||||||
|
)
|
||||||
|
from browser_cli.commands.rendering.windows_tree import build_windows_tree
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BROWSER_FAMILY_STYLES",
|
||||||
|
"Column",
|
||||||
|
"DEFAULT_BROWSER_STYLE",
|
||||||
|
"DEFAULT_SCOPE",
|
||||||
|
"TabsTreeBuilder",
|
||||||
|
"browser_label_key",
|
||||||
|
"browser_label_style",
|
||||||
|
"browser_scope",
|
||||||
|
"build_tabs_tree",
|
||||||
|
"build_windows_tree",
|
||||||
|
"group_tree_label",
|
||||||
|
"item_value",
|
||||||
|
"no_wrap_text",
|
||||||
|
"print_table_rows",
|
||||||
|
"print_tree",
|
||||||
|
"scoped_browser_label",
|
||||||
|
"shorten",
|
||||||
|
"tab_group_id",
|
||||||
|
"tab_sort_key",
|
||||||
|
"tab_tree_label",
|
||||||
|
"tab_window_id",
|
||||||
|
"terminal_width",
|
||||||
|
"tree_title_limit",
|
||||||
|
"tree_url_limit",
|
||||||
|
]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Common Rich rendering helpers for CLI command modules."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from typing import TypeVar, cast
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
Row = object
|
||||||
|
CellValue = object
|
||||||
|
Column = tuple[str, Callable[[Row], CellValue]]
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def item_value(item: Row, name: str, default: T | None = None) -> CellValue | T | None:
|
||||||
|
"""Read *name* from a dict-like or attribute object."""
|
||||||
|
if isinstance(item, Mapping):
|
||||||
|
return cast(Mapping[str, CellValue], item).get(name, default)
|
||||||
|
return getattr(item, name, default)
|
||||||
|
|
||||||
|
def text_value(value: CellValue | None, default: str = "") -> str:
|
||||||
|
"""Coerce a nullable cell value to display text."""
|
||||||
|
return default if value is None else str(value)
|
||||||
|
|
||||||
|
def int_value(value: CellValue | None, default: int = 0) -> int:
|
||||||
|
"""Coerce a cell value to int, falling back when conversion is not possible."""
|
||||||
|
try:
|
||||||
|
return int(cast(int | str | float | bool, value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def shorten(value: str | None, limit: int) -> str:
|
||||||
|
"""Return *value* shortened to *limit* cells-ish, using an ellipsis."""
|
||||||
|
value = value or ""
|
||||||
|
return value if len(value) <= limit else value[:max(0, limit - 1)] + "…"
|
||||||
|
|
||||||
|
def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int:
|
||||||
|
"""Best-effort terminal width for interactive and redirected output.
|
||||||
|
|
||||||
|
Rich falls back to 80 columns when stdout is redirected. browser-cli output is
|
||||||
|
often piped into files for inspection, so also consult ``shutil``/``COLUMNS``
|
||||||
|
and prefer the wider value.
|
||||||
|
"""
|
||||||
|
rich_width = (console.width if console is not None else 0) or 0
|
||||||
|
shell_width = shutil.get_terminal_size((fallback, 20)).columns
|
||||||
|
return max(rich_width, shell_width)
|
||||||
|
|
||||||
|
def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int:
|
||||||
|
"""Title width for tree labels, reserving space for branches/IDs/metadata."""
|
||||||
|
reserve = 48 if show_urls else 32
|
||||||
|
if show_browser:
|
||||||
|
reserve += 4
|
||||||
|
return max(50, terminal_width(console) - reserve)
|
||||||
|
|
||||||
|
def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int:
|
||||||
|
"""URL width for tree labels when URLs are displayed."""
|
||||||
|
return max(35, terminal_width(console) - title_limit - 40)
|
||||||
|
|
||||||
|
def print_tree(tree: Tree, *, console: Console | None = None) -> None:
|
||||||
|
"""Render a Rich tree using the detected full terminal width."""
|
||||||
|
Console(width=terminal_width(console)).print(tree)
|
||||||
|
|
||||||
|
def print_table_rows(
|
||||||
|
rows: Sequence[Row],
|
||||||
|
columns: Sequence[Column],
|
||||||
|
*,
|
||||||
|
console: Console,
|
||||||
|
empty_message: str,
|
||||||
|
show_header: bool = True,
|
||||||
|
header_style: str = "bold cyan",
|
||||||
|
) -> None:
|
||||||
|
"""Render a small Rich table from arbitrary row objects."""
|
||||||
|
if not rows:
|
||||||
|
console.print(empty_message)
|
||||||
|
return
|
||||||
|
table = Table(show_header=show_header, header_style=header_style)
|
||||||
|
for header, _getter in columns:
|
||||||
|
table.add_column(header)
|
||||||
|
for row in rows:
|
||||||
|
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
|
||||||
|
Console(width=terminal_width(console)).print(table)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Rich label helpers for tab/window tree renderers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from browser_cli.commands.rendering.common import Row, int_value, item_value, shorten, text_value
|
||||||
|
|
||||||
|
BROWSER_FAMILY_STYLES = {
|
||||||
|
"firefox": "orange1",
|
||||||
|
"chrome": "cyan",
|
||||||
|
"chromium": "cyan",
|
||||||
|
"brave": "cyan",
|
||||||
|
"edge": "cyan",
|
||||||
|
"vivaldi": "cyan",
|
||||||
|
}
|
||||||
|
DEFAULT_SCOPE = "local"
|
||||||
|
DEFAULT_BROWSER_STYLE = "bold cyan"
|
||||||
|
|
||||||
|
def no_wrap_text() -> Text:
|
||||||
|
"""Text configured for one-line tree labels with edge ellipsis."""
|
||||||
|
return Text(no_wrap=True, overflow="ellipsis")
|
||||||
|
|
||||||
|
def tab_tree_label(tab: Row, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text:
|
||||||
|
"""Reusable one-line label for a browser tab in tree views."""
|
||||||
|
label = no_wrap_text()
|
||||||
|
label.append(f"[{text_value(item_value(tab, 'id'))}] ", style="dim")
|
||||||
|
label.append(shorten(text_value(item_value(tab, 'title'), "(untitled)") or "(untitled)", title_limit))
|
||||||
|
if bool(item_value(tab, "active", False)):
|
||||||
|
label.append(" *", style="green")
|
||||||
|
url = text_value(item_value(tab, "url"))
|
||||||
|
if show_urls and url:
|
||||||
|
label.append(" — ", style="dim")
|
||||||
|
label.append(shorten(url, url_limit), style="dim")
|
||||||
|
return label
|
||||||
|
|
||||||
|
def group_tree_label(group_id: object, group: Row | None, *, title_limit: int) -> Text:
|
||||||
|
"""Reusable one-line label for a browser tab group in tree views."""
|
||||||
|
title = text_value(item_value(group, "title", "") if group is not None else "") or f"Group {group_id}"
|
||||||
|
color = text_value(item_value(group, "color", "") if group is not None else "") or "group"
|
||||||
|
count = int_value(item_value(group, "tab_count", item_value(group, "tabCount", 0)) if group is not None else 0)
|
||||||
|
collapsed = bool(item_value(group, "collapsed", False)) if group is not None else False
|
||||||
|
label = no_wrap_text()
|
||||||
|
label.append(shorten(title, title_limit), style="bold")
|
||||||
|
meta = [color]
|
||||||
|
if count:
|
||||||
|
meta.append(f"{count} tab" + ("" if count == 1 else "s"))
|
||||||
|
if collapsed:
|
||||||
|
meta.append("collapsed")
|
||||||
|
label.append(" (" + ", ".join(meta) + ")", style="dim")
|
||||||
|
return label
|
||||||
|
|
||||||
|
def browser_label_style(browser_name: str | None) -> str:
|
||||||
|
"""Return a Rich style for a browser family label."""
|
||||||
|
name = (browser_name or "").lower()
|
||||||
|
for family, style in BROWSER_FAMILY_STYLES.items():
|
||||||
|
if family in name:
|
||||||
|
return style
|
||||||
|
return DEFAULT_BROWSER_STYLE
|
||||||
|
|
||||||
|
def scoped_browser_label(browser: str, scope: str, *, grouped: bool) -> str:
|
||||||
|
"""Shorten browser labels under a remote/local group node."""
|
||||||
|
prefix = f"{scope}:"
|
||||||
|
return browser[len(prefix):] if grouped and browser.startswith(prefix) else browser
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Tabs tree renderer."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.text import Text
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
|
||||||
|
from browser_cli.commands.rendering.labels import (
|
||||||
|
DEFAULT_BROWSER_STYLE,
|
||||||
|
DEFAULT_SCOPE,
|
||||||
|
browser_label_style,
|
||||||
|
group_tree_label,
|
||||||
|
scoped_browser_label,
|
||||||
|
tab_tree_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
GroupId = object
|
||||||
|
GroupKey = tuple[str, str, int | None, GroupId]
|
||||||
|
TreeNodeKey = tuple[str, str]
|
||||||
|
WindowNodeKey = tuple[str, str, int]
|
||||||
|
BrowserGroupNodeKey = tuple[str, str, int, GroupId]
|
||||||
|
|
||||||
|
def browser_scope(item: Row) -> str:
|
||||||
|
"""Return the remote/local scope key used by tree renderers."""
|
||||||
|
return text_value(item_value(item, "browser_group")) or DEFAULT_SCOPE
|
||||||
|
|
||||||
|
def browser_label_key(item: Row) -> str:
|
||||||
|
"""Return the browser/profile key used by tree renderers."""
|
||||||
|
return text_value(item_value(item, "browser")) or DEFAULT_SCOPE
|
||||||
|
|
||||||
|
def tab_window_id(tab: Row) -> int:
|
||||||
|
"""Return a stable window id from object or dict-shaped tab responses."""
|
||||||
|
return int_value(item_value(tab, "window_id", item_value(tab, "windowId", 0)))
|
||||||
|
|
||||||
|
def tab_group_id(tab: Row) -> GroupId | None:
|
||||||
|
"""Return a tab group id from object or dict-shaped tab responses."""
|
||||||
|
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
|
||||||
|
return None if group_id is None else group_id
|
||||||
|
|
||||||
|
def tab_sort_key(tab: Row) -> tuple[str, str, int, int, int, int]:
|
||||||
|
"""Stable tab ordering across multi-browser responses."""
|
||||||
|
group_id = tab_group_id(tab)
|
||||||
|
return (
|
||||||
|
browser_scope(tab),
|
||||||
|
browser_label_key(tab),
|
||||||
|
tab_window_id(tab),
|
||||||
|
int_value(item_value(tab, "index", 0)),
|
||||||
|
int_value(group_id, -1) if group_id is not None else -1,
|
||||||
|
int_value(item_value(tab, "id", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TabsTreeBuilder:
|
||||||
|
"""Stateful builder for the browser tabs tree.
|
||||||
|
|
||||||
|
The tree has optional scope nodes (remote host/local), then browser/profile,
|
||||||
|
then window, then browser tab-groups/tabs. Keeping this state in a helper
|
||||||
|
keeps ``build_tabs_tree`` small while preserving stable node reuse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tabs: list[Row]
|
||||||
|
groups: list[Row]
|
||||||
|
show_urls: bool
|
||||||
|
show_browser: bool
|
||||||
|
group_by_scope: bool
|
||||||
|
title_limit: int
|
||||||
|
url_limit: int
|
||||||
|
root: Tree
|
||||||
|
group_info: dict[GroupKey, Row]
|
||||||
|
browser_styles: dict[str, str]
|
||||||
|
scope_nodes: dict[str, Tree]
|
||||||
|
browser_nodes: dict[TreeNodeKey, Tree]
|
||||||
|
window_nodes: dict[WindowNodeKey, Tree]
|
||||||
|
group_nodes: dict[BrowserGroupNodeKey, Tree]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tabs: Iterable[Row],
|
||||||
|
groups: Iterable[Row],
|
||||||
|
*,
|
||||||
|
console: Console,
|
||||||
|
show_urls: bool = False,
|
||||||
|
):
|
||||||
|
self.tabs = sorted(tabs, key=tab_sort_key)
|
||||||
|
self.groups = list(groups)
|
||||||
|
self.show_urls = show_urls
|
||||||
|
self.show_browser = any(bool(item_value(tab, "browser")) for tab in self.tabs)
|
||||||
|
self.group_by_scope = any(bool(item_value(item, "browser_group")) for item in self.tabs + self.groups)
|
||||||
|
self.title_limit = tree_title_limit(console=console, show_browser=self.show_browser, show_urls=show_urls)
|
||||||
|
self.url_limit = tree_url_limit(self.title_limit, console=console)
|
||||||
|
self.root = Tree("[bold]Tabs[/bold]")
|
||||||
|
self.group_info = self._group_info()
|
||||||
|
self.browser_styles = self._browser_styles()
|
||||||
|
self.scope_nodes = {}
|
||||||
|
self.browser_nodes = {}
|
||||||
|
self.window_nodes = {}
|
||||||
|
self.group_nodes = {}
|
||||||
|
|
||||||
|
def build(self) -> Tree:
|
||||||
|
for tab in self.tabs:
|
||||||
|
self._add_tab(tab)
|
||||||
|
return self.root
|
||||||
|
|
||||||
|
def _group_info(self) -> dict[GroupKey, Row]:
|
||||||
|
return {
|
||||||
|
(
|
||||||
|
browser_scope(group),
|
||||||
|
browser_label_key(group),
|
||||||
|
int_value(item_value(group, "window_id", item_value(group, "windowId")), 0),
|
||||||
|
item_value(group, "id"),
|
||||||
|
): group
|
||||||
|
for group in self.groups
|
||||||
|
}
|
||||||
|
|
||||||
|
def _browser_styles(self) -> dict[str, str]:
|
||||||
|
styles: dict[str, str] = {}
|
||||||
|
for item in self.tabs + self.groups:
|
||||||
|
key = browser_label_key(item)
|
||||||
|
styles.setdefault(key, browser_label_style(text_value(item_value(item, "browser_name")) or None))
|
||||||
|
return styles
|
||||||
|
|
||||||
|
def _scope_node(self, scope: str) -> Tree:
|
||||||
|
if not self.group_by_scope:
|
||||||
|
return self.root
|
||||||
|
node = self.scope_nodes.get(scope)
|
||||||
|
if node is None:
|
||||||
|
node = self.root.add(Text(scope, style="bold"))
|
||||||
|
self.scope_nodes[scope] = node
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _browser_node(self, scope: str, browser: str) -> Tree:
|
||||||
|
key = (scope, browser)
|
||||||
|
node = self.browser_nodes.get(key)
|
||||||
|
if node is None:
|
||||||
|
parent = self._scope_node(scope)
|
||||||
|
if self.show_browser:
|
||||||
|
label = scoped_browser_label(browser, scope, grouped=self.group_by_scope)
|
||||||
|
node = parent.add(Text(label, style=self.browser_styles.get(browser, DEFAULT_BROWSER_STYLE)))
|
||||||
|
else:
|
||||||
|
node = parent
|
||||||
|
self.browser_nodes[key] = node
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _window_node(self, scope: str, browser: str, window_id: int) -> Tree:
|
||||||
|
key = (scope, browser, window_id)
|
||||||
|
node = self.window_nodes.get(key)
|
||||||
|
if node is None:
|
||||||
|
node = self._browser_node(scope, browser).add(f"Window {window_id}")
|
||||||
|
self.window_nodes[key] = node
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _group_node(self, scope: str, browser: str, window_id: int, group_id: GroupId, parent: Tree) -> Tree:
|
||||||
|
key = (scope, browser, window_id, group_id)
|
||||||
|
node = self.group_nodes.get(key)
|
||||||
|
if node is None:
|
||||||
|
group = self.group_info.get(key) or self.group_info.get((scope, browser, None, group_id))
|
||||||
|
node = parent.add(group_tree_label(group_id, group, title_limit=self.title_limit))
|
||||||
|
self.group_nodes[key] = node
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _add_tab(self, tab: Row) -> None:
|
||||||
|
scope = browser_scope(tab)
|
||||||
|
browser = browser_label_key(tab)
|
||||||
|
window_id = tab_window_id(tab)
|
||||||
|
window_node = self._window_node(scope, browser, window_id)
|
||||||
|
group_id = tab_group_id(tab)
|
||||||
|
parent = window_node if group_id is None else self._group_node(scope, browser, window_id, group_id, window_node)
|
||||||
|
parent.add(tab_tree_label(
|
||||||
|
tab,
|
||||||
|
title_limit=self.title_limit,
|
||||||
|
show_urls=self.show_urls,
|
||||||
|
url_limit=self.url_limit,
|
||||||
|
))
|
||||||
|
|
||||||
|
def build_tabs_tree(
|
||||||
|
tabs: Iterable[Row],
|
||||||
|
groups: Iterable[Row],
|
||||||
|
*,
|
||||||
|
console: Console,
|
||||||
|
show_urls: bool = False,
|
||||||
|
) -> Tree:
|
||||||
|
"""Build a remote/local → browser → window → group/tab tree from tab responses."""
|
||||||
|
return TabsTreeBuilder(tabs, groups, console=console, show_urls=show_urls).build()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Windows tree renderer."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
|
||||||
|
from browser_cli.commands.rendering.labels import tab_tree_label
|
||||||
|
|
||||||
|
WindowRow = Mapping[str, object]
|
||||||
|
|
||||||
|
def build_windows_tree(windows: Iterable[WindowRow], tabs: Iterable[Row], *, console: Console) -> Tree:
|
||||||
|
"""Build a window → tab tree from window and tab responses."""
|
||||||
|
windows = list(windows)
|
||||||
|
tabs = list(tabs)
|
||||||
|
title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
|
||||||
|
url_limit = tree_url_limit(title_limit, console=console)
|
||||||
|
root = Tree("[bold]Windows[/bold]")
|
||||||
|
for window in sorted(windows, key=lambda item: (text_value(item.get("browser")), int_value(item.get("id")))):
|
||||||
|
window_id = int_value(window.get("id"))
|
||||||
|
label = f"Window {window_id}"
|
||||||
|
alias = text_value(window.get("alias"))
|
||||||
|
browser = text_value(window.get("browser"))
|
||||||
|
if alias:
|
||||||
|
label += f" ({alias})"
|
||||||
|
if browser:
|
||||||
|
label = f"{browser}: " + label
|
||||||
|
node = root.add(label)
|
||||||
|
window_tabs = [
|
||||||
|
tab for tab in tabs
|
||||||
|
if int_value(item_value(tab, "window_id", item_value(tab, "windowId"))) == window_id
|
||||||
|
and (not browser or text_value(item_value(tab, "browser")) == browser)
|
||||||
|
]
|
||||||
|
for tab in sorted(window_tabs, key=lambda item: int_value(item_value(item, "index", 0))):
|
||||||
|
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
|
||||||
|
return root
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||||
|
from browser_cli.commands import client_from_ctx, handle_errors
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
def _load_steps(path: Path):
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if path.suffix.lower() in {".yaml", ".yml"}:
|
||||||
|
try:
|
||||||
|
yaml = cast(Any, importlib.import_module("yaml"))
|
||||||
|
except Exception as exc:
|
||||||
|
raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc
|
||||||
|
return yaml.safe_load(text)
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
def _parse_step(step):
|
||||||
|
if isinstance(step, str):
|
||||||
|
return step, {}
|
||||||
|
if isinstance(step, dict):
|
||||||
|
if "command" in step:
|
||||||
|
return step["command"], step.get("args") or {}
|
||||||
|
if len(step) == 1:
|
||||||
|
command, args = next(iter(step.items()))
|
||||||
|
return command, args or {}
|
||||||
|
raise click.ClickException(f"Invalid script step: {step!r}")
|
||||||
|
|
||||||
|
@click.command("script")
|
||||||
|
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||||
|
@click.option("--json", "json_output", is_flag=True, help="Print all step results as JSON")
|
||||||
|
@click.option("--continue-on-error", is_flag=True, help="Continue after failed steps")
|
||||||
|
@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
|
||||||
|
@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
|
||||||
|
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
|
||||||
|
@handle_errors
|
||||||
|
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool):
|
||||||
|
"""Run a JSON/YAML batch script of browser-cli wire commands."""
|
||||||
|
steps = _load_steps(file)
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
raise click.ClickException("Script root must be a list")
|
||||||
|
client = client_from_ctx()
|
||||||
|
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
|
||||||
|
results = []
|
||||||
|
for index, step in enumerate(steps, start=1):
|
||||||
|
command, args = _parse_step(step)
|
||||||
|
try:
|
||||||
|
assert_command_allowed(command, policy)
|
||||||
|
result = client.command(command, args)
|
||||||
|
results.append({"index": index, "command": command, "ok": True, "result": result})
|
||||||
|
if not json_output:
|
||||||
|
console.print(f"[green]✓[/green] {index}: {command}")
|
||||||
|
except Exception as exc:
|
||||||
|
results.append({"index": index, "command": command, "ok": False, "error": str(exc)})
|
||||||
|
if not continue_on_error:
|
||||||
|
if json_output:
|
||||||
|
click.echo(json.dumps(results, indent=2, default=str))
|
||||||
|
raise
|
||||||
|
if not json_output:
|
||||||
|
console.print(f"[red]✗[/red] {index}: {command}: {exc}")
|
||||||
|
if json_output:
|
||||||
|
click.echo(json.dumps(results, indent=2, default=str))
|
||||||
@@ -63,13 +63,12 @@ def search_group():
|
|||||||
def _build_command(engine_key: str, help_text: str) -> click.Command:
|
def _build_command(engine_key: str, help_text: str) -> click.Command:
|
||||||
@click.command(engine_key, help=help_text)
|
@click.command(engine_key, help=help_text)
|
||||||
@click.argument("query", nargs=-1, required=True)
|
@click.argument("query", nargs=-1, required=True)
|
||||||
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
|
|
||||||
@click.option("--window", "window", default=None, help="Open in named window")
|
@click.option("--window", "window", default=None, help="Open in named window")
|
||||||
@click.option("--group", "group", default=None, help="Open in tab group (name or ID)")
|
@click.option("--group", "group", default=None, help="Open in tab group (name or ID)")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def _cmd(query, bg, window, group):
|
def _cmd(query, window, group):
|
||||||
terms = " ".join(query)
|
terms = " ".join(query)
|
||||||
client_from_ctx().nav.search(engine_key, terms, background=bg, window=window, group=group)
|
client_from_ctx().nav.search(engine_key, terms, window=window, group=group)
|
||||||
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
|
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
|
||||||
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
||||||
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
|
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
|
||||||
|
|||||||
@@ -45,32 +45,9 @@ __all__ = [
|
|||||||
default=False,
|
default=False,
|
||||||
help="Disable response compression / msgpack even for clients that support it.",
|
help="Disable response compression / msgpack even for clients that support it.",
|
||||||
)
|
)
|
||||||
@click.option(
|
|
||||||
"--rpc",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Also expose a ServiceLink HTTP /rpc endpoint (for mesh nodes) in the same process.",
|
|
||||||
)
|
|
||||||
@click.option("--rpc-port", default=8770, show_default=True, type=int, help="Port for the /rpc endpoint (with --rpc).")
|
|
||||||
@click.option(
|
|
||||||
"--rpc-token",
|
|
||||||
default=None,
|
|
||||||
metavar="SECRET",
|
|
||||||
help="Bearer token required on /rpc (with --rpc).",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--rpc-insecure",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Allow --rpc with no token (DANGEROUS: full browser control to anyone who can reach the port).",
|
|
||||||
)
|
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rpc, rpc_port, rpc_token, rpc_insecure):
|
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
|
||||||
"""Expose this browser over TCP so remote hosts can control it.
|
"""Expose this browser over TCP so remote hosts can control it."""
|
||||||
|
|
||||||
With --rpc, additionally serve the ServiceLink mesh over HTTP /rpc on
|
|
||||||
--rpc-port, so the native TCP protocol and the node mesh share one daemon.
|
|
||||||
"""
|
|
||||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||||
compress = not no_compress
|
compress = not no_compress
|
||||||
|
|
||||||
@@ -84,40 +61,16 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rpc, rpc_po
|
|||||||
if auth_keys_path is False:
|
if auth_keys_path is False:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if rpc and not rpc_token and not rpc_insecure:
|
|
||||||
console.print(
|
|
||||||
"[red]Error:[/red] --rpc requires --rpc-token (this endpoint can control your "
|
|
||||||
"browser and read its cookies). Use --rpc-insecure to override on a trusted host."
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
_print_startup(host, port, profile, auth_keys_path, compress)
|
_print_startup(host, port, profile, auth_keys_path, compress)
|
||||||
if rpc:
|
|
||||||
console.print(f" Mesh: [green]ServiceLink HTTP[/green] [cyan]{host}:{rpc_port}/rpc[/cyan]")
|
|
||||||
if not rpc_token:
|
|
||||||
console.print("[yellow] /rpc auth disabled (--rpc-insecure)[/yellow]")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if rpc:
|
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
|
||||||
asyncio.run(_serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token))
|
|
||||||
else:
|
|
||||||
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("[yellow]Stopped.[/yellow]")
|
console.print("[yellow]Stopped.[/yellow]")
|
||||||
|
|
||||||
async def _serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token):
|
|
||||||
"""Run the native TCP server and the ServiceLink HTTP /rpc server together."""
|
|
||||||
from browser_cli.commands import link_serve
|
|
||||||
|
|
||||||
sl = link_serve._import_servicelink()
|
|
||||||
await asyncio.gather(
|
|
||||||
_serve_async(host, port, profile, auth_keys_path, compress),
|
|
||||||
link_serve._serve(sl, host, rpc_port, profile, rpc_token),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
|
def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
|
||||||
if auth_keys_file:
|
if auth_keys_file:
|
||||||
from browser_cli.auth import load_authorized_keys
|
from browser_cli.auth import load_authorized_keys
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from browser_cli import BrowserCLI
|
||||||
|
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
def _is_loopback(host: str) -> bool:
|
||||||
|
return host in {"127.0.0.1", "localhost", "::1"}
|
||||||
|
|
||||||
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
client: BrowserCLI
|
||||||
|
token: str | None = None
|
||||||
|
policy: CommandPolicy = CommandPolicy()
|
||||||
|
|
||||||
|
def _authorized(self) -> bool:
|
||||||
|
if self.token is None:
|
||||||
|
return True
|
||||||
|
if self.headers.get("Authorization", "") == f"Bearer {self.token}":
|
||||||
|
return True
|
||||||
|
return self.headers.get("X-Browser-CLI-Token") == self.token
|
||||||
|
|
||||||
|
def _require_auth(self) -> bool:
|
||||||
|
if self._authorized():
|
||||||
|
return True
|
||||||
|
self._send(401, {"error": "missing or invalid token"})
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _send(self, status: int, payload):
|
||||||
|
raw = json.dumps(payload, default=str).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(raw)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(raw)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
try:
|
||||||
|
if path != "/health" and not self._require_auth():
|
||||||
|
return
|
||||||
|
if path == "/tabs":
|
||||||
|
self._send(200, [t.__dict__ for t in self.client.tabs.list()])
|
||||||
|
elif path == "/clients":
|
||||||
|
self._send(200, self.client.clients())
|
||||||
|
elif path == "/health":
|
||||||
|
self._send(200, {"ok": True})
|
||||||
|
else:
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
except Exception as exc:
|
||||||
|
self._send(500, {"error": str(exc)})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
if path == "/command":
|
||||||
|
if not self._require_auth():
|
||||||
|
return
|
||||||
|
command = body.get("command")
|
||||||
|
assert_command_allowed(command, self.policy)
|
||||||
|
self._send(200, {"result": self.client.command(command, body.get("args") or {})})
|
||||||
|
else:
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
except PermissionError as exc:
|
||||||
|
self._send(403, {"error": str(exc)})
|
||||||
|
except Exception as exc:
|
||||||
|
self._send(500, {"error": str(exc)})
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
console.print(f"[dim]http[/dim] {self.address_string()} {fmt % args}")
|
||||||
|
|
||||||
|
@click.command("serve-http")
|
||||||
|
@click.option("--host", default="127.0.0.1", show_default=True)
|
||||||
|
@click.option("--port", type=int, default=8766, show_default=True)
|
||||||
|
@click.option("--browser", default=None, help="Browser alias to target")
|
||||||
|
@click.option("--remote", default=None, help="Remote endpoint to target")
|
||||||
|
@click.option("--key", default=None, help="Remote auth key spec")
|
||||||
|
@click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)")
|
||||||
|
@click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)")
|
||||||
|
@click.option("--allow-read-page", is_flag=True, help="Allow /command to run page-content read commands")
|
||||||
|
@click.option("--allow-control", is_flag=True, help="Allow /command to run browser-control commands")
|
||||||
|
@click.option("--allow-dangerous", is_flag=True, help="Allow /command to run high-risk commands")
|
||||||
|
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous):
|
||||||
|
"""Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
|
||||||
|
|
||||||
|
Auth is enabled by default. Pass the printed token as either
|
||||||
|
``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``.
|
||||||
|
"""
|
||||||
|
if no_auth and not _is_loopback(host):
|
||||||
|
raise click.ClickException("--no-auth is only allowed on loopback hosts")
|
||||||
|
auth_token = None if no_auth else (token or secrets.token_urlsafe(32))
|
||||||
|
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
|
||||||
|
handler = type(
|
||||||
|
"BrowserCLIHTTPHandler",
|
||||||
|
(_Handler,),
|
||||||
|
{"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy},
|
||||||
|
)
|
||||||
|
server = ThreadingHTTPServer((host, port), handler)
|
||||||
|
console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
|
||||||
|
if auth_token:
|
||||||
|
console.print(f"[yellow]Token:[/yellow] {auth_token}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[yellow]Stopping HTTP gateway[/yellow]")
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
import click
|
import click
|
||||||
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors
|
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@@ -44,6 +46,33 @@ def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, b
|
|||||||
count = result.get("tabs", 0) if isinstance(result, dict) else 0
|
count = result.get("tabs", 0) if isinstance(result, dict) else 0
|
||||||
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
|
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
|
||||||
|
|
||||||
|
@session_group.command("export")
|
||||||
|
@click.argument("name", required=False)
|
||||||
|
@click.option("-o", "output", type=click.Path(dir_okay=False, path_type=Path), default=None, help="Write JSON to file instead of stdout")
|
||||||
|
@handle_errors
|
||||||
|
def session_export(name, output):
|
||||||
|
"""Export one saved session, or all sessions as JSON."""
|
||||||
|
data = client_from_ctx().session.export(name)
|
||||||
|
text = json.dumps(data, indent=2, sort_keys=True)
|
||||||
|
if output:
|
||||||
|
output.write_text(text + "\n", encoding="utf-8")
|
||||||
|
console.print(f"[green]Exported session data to {output}[/green]")
|
||||||
|
else:
|
||||||
|
click.echo(text)
|
||||||
|
|
||||||
|
@session_group.command("import")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||||
|
@click.option("--overwrite", is_flag=True, help="Replace an existing saved session")
|
||||||
|
@handle_errors
|
||||||
|
def session_import(name, file, overwrite):
|
||||||
|
"""Import a saved session JSON file."""
|
||||||
|
payload = json.loads(file.read_text(encoding="utf-8"))
|
||||||
|
session = payload.get("session", payload) if isinstance(payload, dict) else payload
|
||||||
|
result = client_from_ctx().session.import_(name, session, overwrite=overwrite)
|
||||||
|
count = result.get("tabs", 0) if isinstance(result, dict) else 0
|
||||||
|
console.print(f"[green]Imported session '{name}'[/green] ({count} tabs)")
|
||||||
|
|
||||||
@session_group.command("diff")
|
@session_group.command("diff")
|
||||||
@click.argument("name_a")
|
@click.argument("name_a")
|
||||||
@click.argument("name_b")
|
@click.argument("name_b")
|
||||||
|
|||||||
@@ -2,49 +2,45 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import click
|
import click
|
||||||
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
|
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
|
||||||
|
from browser_cli.commands.rendering import build_tabs_tree, print_table_rows, print_tree
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
|
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
|
||||||
if not tabs:
|
columns = []
|
||||||
console.print("[yellow]No tabs found[/yellow]")
|
if show_browser:
|
||||||
return
|
columns.append(("Browser", lambda tab: tab.browser or ""))
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
columns.extend([
|
||||||
if show_browser:
|
("ID", lambda tab: tab.id),
|
||||||
table.add_column("Browser", no_wrap=True)
|
("Window", lambda tab: tab.window_id),
|
||||||
table.add_column("ID", style="dim", no_wrap=True)
|
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
|
||||||
table.add_column("Window", no_wrap=True)
|
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
|
||||||
table.add_column("Active", width=7)
|
("Title", lambda tab: (tab.title or "")[:60]),
|
||||||
table.add_column("Muted", width=7)
|
("URL", lambda tab: (tab.url or "")[:80]),
|
||||||
table.add_column("Title")
|
])
|
||||||
table.add_column("URL")
|
print_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
|
||||||
for t in tabs:
|
|
||||||
active = "[green]✓[/green]" if t.active else ""
|
|
||||||
muted = "[yellow]✓[/yellow]" if t.muted else ""
|
|
||||||
row = [
|
|
||||||
(t.browser or "") if show_browser else None,
|
|
||||||
str(t.id),
|
|
||||||
str(t.window_id),
|
|
||||||
active,
|
|
||||||
muted,
|
|
||||||
(t.title or "")[:60],
|
|
||||||
(t.url or "")[:80],
|
|
||||||
]
|
|
||||||
table.add_row(*[value for value in row if value is not None])
|
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
@click.group("tabs")
|
@click.group("tabs")
|
||||||
def tabs_group():
|
def tabs_group():
|
||||||
"""Manage browser tabs."""
|
"""Manage browser tabs."""
|
||||||
|
|
||||||
@tabs_group.command("list")
|
@tabs_group.command("list")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_list():
|
def tabs_list():
|
||||||
"""List all open tabs across all windows."""
|
"""List all open tabs across all windows."""
|
||||||
tabs = client_from_ctx().tabs.list()
|
tabs = client_from_ctx().tabs.list()
|
||||||
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
|
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
|
||||||
|
|
||||||
|
@tabs_group.command("tree")
|
||||||
|
@click.option("--urls", "show_urls", is_flag=True, help="Show shortened URLs next to tab titles")
|
||||||
|
@handle_errors
|
||||||
|
def tabs_tree(show_urls):
|
||||||
|
"""Show tabs grouped as a window/group tree."""
|
||||||
|
client = client_from_ctx()
|
||||||
|
root = build_tabs_tree(client.tabs.list(), client.groups.list(), console=console, show_urls=show_urls)
|
||||||
|
print_tree(root, console=console)
|
||||||
|
|
||||||
@tabs_group.command("close")
|
@tabs_group.command("close")
|
||||||
@click.argument("tab_id", type=int, required=False)
|
@click.argument("tab_id", type=int, required=False)
|
||||||
@@ -53,9 +49,9 @@ def tabs_list():
|
|||||||
@gentle_mode_option("Throttle mode for large close operations.")
|
@gentle_mode_option("Throttle mode for large close operations.")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
|
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
|
||||||
"""Close a tab, all inactive tabs, or all duplicate tabs."""
|
"""Close a tab, all inactive tabs, or all duplicate tabs."""
|
||||||
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
|
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
|
||||||
console.print(f"[green]Closed {count} tab(s)[/green]")
|
console.print(f"[green]Closed {count} tab(s)[/green]")
|
||||||
|
|
||||||
@tabs_group.command("move")
|
@tabs_group.command("move")
|
||||||
@click.argument("tab_id", type=int)
|
@click.argument("tab_id", type=int)
|
||||||
@@ -68,123 +64,123 @@ def tabs_close(tab_id, inactive, duplicates, gentle_mode):
|
|||||||
@click.option("--index", type=int, default=None, help="Absolute position index in target")
|
@click.option("--index", type=int, default=None, help="Absolute position index in target")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
|
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
|
||||||
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
|
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
|
||||||
client_from_ctx().tabs.move(
|
client_from_ctx().tabs.move(
|
||||||
tab_id, forward=forward, backward=backward,
|
tab_id, forward=forward, backward=backward,
|
||||||
group_id=group_id, window_id=window_id, index=index,
|
group_id=group_id, window_id=window_id, index=index,
|
||||||
)
|
)
|
||||||
console.print("[green]Tab moved[/green]")
|
console.print("[green]Tab moved[/green]")
|
||||||
|
|
||||||
@tabs_group.command("active")
|
@tabs_group.command("active")
|
||||||
@click.argument("tab_id", type=int)
|
@click.argument("tab_id", type=int)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_active(tab_id):
|
def tabs_active(tab_id):
|
||||||
"""Switch browser focus to a tab."""
|
"""Switch browser focus to a tab."""
|
||||||
client_from_ctx().tabs.activate(tab_id)
|
client_from_ctx().tabs.activate(tab_id)
|
||||||
console.print(f"[green]Switched to tab {tab_id}[/green]")
|
console.print(f"[green]Switched to tab {tab_id}[/green]")
|
||||||
|
|
||||||
@tabs_group.command("status")
|
@tabs_group.command("status")
|
||||||
@click.argument("tab_id", type=int, required=False)
|
@click.argument("tab_id", type=int, required=False)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_status(tab_id):
|
def tabs_status(tab_id):
|
||||||
"""Show status for the active tab or a specific tab."""
|
"""Show status for the active tab or a specific tab."""
|
||||||
tab = client_from_ctx().tabs.status(tab_id)
|
tab = client_from_ctx().tabs.status(tab_id)
|
||||||
table = Table(show_header=False)
|
table = Table(show_header=False)
|
||||||
table.add_column("Field", style="bold cyan")
|
table.add_column("Field", style="bold cyan")
|
||||||
table.add_column("Value")
|
table.add_column("Value")
|
||||||
table.add_row("ID", str(tab.id))
|
table.add_row("ID", str(tab.id))
|
||||||
table.add_row("Window", str(tab.window_id))
|
table.add_row("Window", str(tab.window_id))
|
||||||
table.add_row("Active", "yes" if tab.active else "no")
|
table.add_row("Active", "yes" if tab.active else "no")
|
||||||
table.add_row("Muted", "yes" if tab.muted else "no")
|
table.add_row("Muted", "yes" if tab.muted else "no")
|
||||||
table.add_row("Title", tab.title or "")
|
table.add_row("Title", tab.title or "")
|
||||||
table.add_row("URL", tab.url or "")
|
table.add_row("URL", tab.url or "")
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
@tabs_group.command("filter")
|
@tabs_group.command("filter")
|
||||||
@click.argument("pattern")
|
@click.argument("pattern")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_filter(pattern):
|
def tabs_filter(pattern):
|
||||||
"""List tabs whose URL contains PATTERN."""
|
"""List tabs whose URL contains PATTERN."""
|
||||||
_print_tabs(client_from_ctx().tabs.filter(pattern))
|
_print_tabs(client_from_ctx().tabs.filter(pattern))
|
||||||
|
|
||||||
@tabs_group.command("count")
|
@tabs_group.command("count")
|
||||||
@click.argument("pattern", required=False)
|
@click.argument("pattern", required=False)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_count(pattern):
|
def tabs_count(pattern):
|
||||||
"""Count open tabs, optionally filtered by URL PATTERN."""
|
"""Count open tabs, optionally filtered by URL PATTERN."""
|
||||||
label = f" matching '{pattern}'" if pattern else ""
|
label = f" matching '{pattern}'" if pattern else ""
|
||||||
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
|
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
|
||||||
|
|
||||||
@tabs_group.command("query")
|
@tabs_group.command("query")
|
||||||
@click.argument("search")
|
@click.argument("search")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_query(search):
|
def tabs_query(search):
|
||||||
"""Search tabs by URL or title."""
|
"""Search tabs by URL or title."""
|
||||||
_print_tabs(client_from_ctx().tabs.query(search))
|
_print_tabs(client_from_ctx().tabs.query(search))
|
||||||
|
|
||||||
@tabs_group.command("html")
|
@tabs_group.command("html")
|
||||||
@click.argument("tab_id", type=int, required=False)
|
@click.argument("tab_id", type=int, required=False)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_html(tab_id):
|
def tabs_html(tab_id):
|
||||||
"""Print the full HTML of a tab."""
|
"""Print the full HTML of a tab."""
|
||||||
console.print(client_from_ctx().tabs.html(tab_id))
|
console.print(client_from_ctx().tabs.html(tab_id))
|
||||||
|
|
||||||
@tabs_group.command("dedupe")
|
@tabs_group.command("dedupe")
|
||||||
@gentle_mode_option("Throttle mode for large dedupe operations.")
|
@gentle_mode_option("Throttle mode for large dedupe operations.")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_dedupe(gentle_mode):
|
def tabs_dedupe(gentle_mode):
|
||||||
"""Close duplicate tabs (keep the first occurrence of each URL)."""
|
"""Close duplicate tabs (keep the first occurrence of each URL)."""
|
||||||
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
|
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
|
||||||
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
|
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
|
||||||
|
|
||||||
@tabs_group.command("sort")
|
@tabs_group.command("sort")
|
||||||
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
|
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
|
||||||
@gentle_mode_option("Throttle mode for large sort operations.")
|
@gentle_mode_option("Throttle mode for large sort operations.")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_sort(by, gentle_mode):
|
def tabs_sort(by, gentle_mode):
|
||||||
"""Sort tabs within each window."""
|
"""Sort tabs within each window."""
|
||||||
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
|
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
|
||||||
console.print(f"[green]Tabs sorted by {by}[/green]")
|
console.print(f"[green]Tabs sorted by {by}[/green]")
|
||||||
|
|
||||||
@tabs_group.command("merge-windows")
|
@tabs_group.command("merge-windows")
|
||||||
@gentle_mode_option("Throttle mode for large merge operations.")
|
@gentle_mode_option("Throttle mode for large merge operations.")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_merge_windows(gentle_mode):
|
def tabs_merge_windows(gentle_mode):
|
||||||
"""Move all tabs into the focused window."""
|
"""Move all tabs into the focused window."""
|
||||||
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
|
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
|
||||||
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
|
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
|
||||||
|
|
||||||
@tabs_group.command("mute")
|
@tabs_group.command("mute")
|
||||||
@click.argument("tab_id", type=int, required=False)
|
@click.argument("tab_id", type=int, required=False)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_mute(tab_id):
|
def tabs_mute(tab_id):
|
||||||
"""Mute the active tab or a specific tab."""
|
"""Mute the active tab or a specific tab."""
|
||||||
target = client_from_ctx().tabs.mute(tab_id)
|
target = client_from_ctx().tabs.mute(tab_id)
|
||||||
console.print(f"[green]Muted tab {target}[/green]")
|
console.print(f"[green]Muted tab {target}[/green]")
|
||||||
|
|
||||||
@tabs_group.command("unmute")
|
@tabs_group.command("unmute")
|
||||||
@click.argument("tab_id", type=int, required=False)
|
@click.argument("tab_id", type=int, required=False)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_unmute(tab_id):
|
def tabs_unmute(tab_id):
|
||||||
"""Unmute the active tab or a specific tab."""
|
"""Unmute the active tab or a specific tab."""
|
||||||
target = client_from_ctx().tabs.unmute(tab_id)
|
target = client_from_ctx().tabs.unmute(tab_id)
|
||||||
console.print(f"[green]Unmuted tab {target}[/green]")
|
console.print(f"[green]Unmuted tab {target}[/green]")
|
||||||
|
|
||||||
@tabs_group.command("pin")
|
@tabs_group.command("pin")
|
||||||
@click.argument("tab_id", type=int, required=False)
|
@click.argument("tab_id", type=int, required=False)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_pin(tab_id):
|
def tabs_pin(tab_id):
|
||||||
"""Pin the active tab or a specific tab."""
|
"""Pin the active tab or a specific tab."""
|
||||||
target = client_from_ctx().tabs.pin(tab_id)
|
target = client_from_ctx().tabs.pin(tab_id)
|
||||||
console.print(f"[green]Pinned tab {target}[/green]")
|
console.print(f"[green]Pinned tab {target}[/green]")
|
||||||
|
|
||||||
@tabs_group.command("unpin")
|
@tabs_group.command("unpin")
|
||||||
@click.argument("tab_id", type=int, required=False)
|
@click.argument("tab_id", type=int, required=False)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_unpin(tab_id):
|
def tabs_unpin(tab_id):
|
||||||
"""Unpin the active tab or a specific tab."""
|
"""Unpin the active tab or a specific tab."""
|
||||||
target = client_from_ctx().tabs.unpin(tab_id)
|
target = client_from_ctx().tabs.unpin(tab_id)
|
||||||
console.print(f"[green]Unpinned tab {target}[/green]")
|
console.print(f"[green]Unpinned tab {target}[/green]")
|
||||||
|
|
||||||
@tabs_group.command("watch-url")
|
@tabs_group.command("watch-url")
|
||||||
@click.argument("pattern")
|
@click.argument("pattern")
|
||||||
@@ -192,9 +188,9 @@ def tabs_unpin(tab_id):
|
|||||||
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_watch_url(pattern, tab_id, timeout):
|
def tabs_watch_url(pattern, tab_id, timeout):
|
||||||
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
|
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
|
||||||
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
|
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
|
||||||
console.print(f"[green]URL matched:[/green] {tab.url}")
|
console.print(f"[green]URL matched:[/green] {tab.url}")
|
||||||
|
|
||||||
@tabs_group.command("screenshot")
|
@tabs_group.command("screenshot")
|
||||||
@click.argument("output", required=False, metavar="FILE")
|
@click.argument("output", required=False, metavar="FILE")
|
||||||
@@ -203,21 +199,21 @@ def tabs_watch_url(pattern, tab_id, timeout):
|
|||||||
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
|
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def tabs_screenshot(output, tab_id, fmt, quality):
|
def tabs_screenshot(output, tab_id, fmt, quality):
|
||||||
"""Capture a screenshot of the active (or specified) tab.
|
"""Capture a screenshot of the active (or specified) tab.
|
||||||
|
|
||||||
Saves to FILE if given, otherwise prints the base64 data URL.
|
Saves to FILE if given, otherwise prints the base64 data URL.
|
||||||
"""
|
"""
|
||||||
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
|
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
|
||||||
if output:
|
if output:
|
||||||
header = f"data:image/{fmt};base64,"
|
header = f"data:image/{fmt};base64,"
|
||||||
if not data_url.startswith(header):
|
if not data_url.startswith(header):
|
||||||
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
|
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
|
||||||
try:
|
try:
|
||||||
raw = base64.b64decode(data_url[len(header):])
|
raw = base64.b64decode(data_url[len(header):])
|
||||||
except binascii.Error as e:
|
except binascii.Error as e:
|
||||||
raise click.ClickException(f"Failed to decode screenshot data: {e}")
|
raise click.ClickException(f"Failed to decode screenshot data: {e}")
|
||||||
with open(output, "wb") as f:
|
with open(output, "wb") as f:
|
||||||
f.write(raw)
|
f.write(raw)
|
||||||
console.print(f"[green]Screenshot saved:[/green] {output}")
|
console.print(f"[green]Screenshot saved:[/green] {output}")
|
||||||
else:
|
else:
|
||||||
console.print(data_url)
|
console.print(data_url)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from browser_cli.commands import client_from_ctx, handle_errors
|
||||||
|
|
||||||
|
@click.group("watch")
|
||||||
|
def watch_group():
|
||||||
|
"""Watch browser state and print changes."""
|
||||||
|
|
||||||
|
@watch_group.command("tabs")
|
||||||
|
@click.option("--interval", type=float, default=1.0, show_default=True)
|
||||||
|
@click.option("--once", is_flag=True)
|
||||||
|
@handle_errors
|
||||||
|
def watch_tabs(interval, once):
|
||||||
|
"""Watch the tab list as JSON snapshots."""
|
||||||
|
client = client_from_ctx()
|
||||||
|
previous = None
|
||||||
|
while True:
|
||||||
|
current = [t.__dict__ for t in client.tabs.list()]
|
||||||
|
if current != previous:
|
||||||
|
click.echo(json.dumps({"type": "tabs", "tabs": current}, default=str), flush=True)
|
||||||
|
previous = current
|
||||||
|
if once:
|
||||||
|
return
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
@watch_group.command("page")
|
||||||
|
@click.option("--field", default=None, help="Only print a single page.info field")
|
||||||
|
@click.option("--interval", type=float, default=1.0, show_default=True)
|
||||||
|
@handle_errors
|
||||||
|
def watch_page(field, interval):
|
||||||
|
"""Watch page.info for the active tab."""
|
||||||
|
client = client_from_ctx()
|
||||||
|
previous = object()
|
||||||
|
while True:
|
||||||
|
info = client.page.info()
|
||||||
|
current = info.get(field) if field else info
|
||||||
|
if current != previous:
|
||||||
|
click.echo(json.dumps({"type": "page", "field": field, "value": current}, default=str), flush=True)
|
||||||
|
previous = current
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
@watch_group.command("dom")
|
||||||
|
@click.argument("selector")
|
||||||
|
@click.option("--interval", type=float, default=1.0, show_default=True)
|
||||||
|
@handle_errors
|
||||||
|
def watch_dom(selector, interval):
|
||||||
|
"""Watch textContent for a selector."""
|
||||||
|
client = client_from_ctx()
|
||||||
|
previous = object()
|
||||||
|
while True:
|
||||||
|
current = client.dom.text(selector)
|
||||||
|
if current != previous:
|
||||||
|
click.echo(json.dumps({"type": "dom", "selector": selector, "text": current}, default=str), flush=True)
|
||||||
|
previous = current
|
||||||
|
time.sleep(interval)
|
||||||
@@ -1,65 +1,63 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.commands import client_from_ctx, handle_errors
|
from browser_cli.commands import client_from_ctx, handle_errors
|
||||||
|
from browser_cli.commands.rendering import build_windows_tree, print_table_rows, print_tree
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
||||||
if not windows:
|
columns = []
|
||||||
console.print("[yellow]No windows found[/yellow]")
|
if show_browser:
|
||||||
return
|
columns.append(("Browser", lambda window: window.get("browser", "")))
|
||||||
table = Table(show_header=True, header_style="bold cyan")
|
columns.extend([
|
||||||
if show_browser:
|
("ID", lambda window: window.get("id", "")),
|
||||||
table.add_column("Browser")
|
("Alias", lambda window: window.get("alias") or ""),
|
||||||
table.add_column("ID", style="dim", no_wrap=True)
|
("Tabs", lambda window: window.get("tabCount", "")),
|
||||||
table.add_column("Alias", width=20)
|
("State", lambda window: window.get("state") or ""),
|
||||||
table.add_column("Tabs", width=6)
|
])
|
||||||
table.add_column("State", width=12)
|
print_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
|
||||||
for w in windows:
|
|
||||||
row = [
|
|
||||||
w.get("browser", "") if show_browser else None,
|
|
||||||
str(w.get("id", "")),
|
|
||||||
w.get("alias") or "",
|
|
||||||
str(w.get("tabCount", "")),
|
|
||||||
w.get("state") or "",
|
|
||||||
]
|
|
||||||
table.add_row(*[value for value in row if value is not None])
|
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
@click.group("windows")
|
@click.group("windows")
|
||||||
def windows_group():
|
def windows_group():
|
||||||
"""Manage browser windows."""
|
"""Manage browser windows."""
|
||||||
|
|
||||||
@windows_group.command("list")
|
@windows_group.command("list")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def windows_list():
|
def windows_list():
|
||||||
"""List all browser windows."""
|
"""List all browser windows."""
|
||||||
windows = client_from_ctx().windows.list()
|
windows = client_from_ctx().windows.list()
|
||||||
_print_windows(windows, show_browser=any("browser" in w for w in windows))
|
_print_windows(windows, show_browser=any("browser" in w for w in windows))
|
||||||
|
|
||||||
|
@windows_group.command("tree")
|
||||||
|
@handle_errors
|
||||||
|
def windows_tree():
|
||||||
|
"""Show windows and their tabs as a tree."""
|
||||||
|
client = client_from_ctx()
|
||||||
|
root = build_windows_tree(client.windows.list(), client.tabs.list(), console=console)
|
||||||
|
print_tree(root, console=console)
|
||||||
|
|
||||||
@windows_group.command("rename")
|
@windows_group.command("rename")
|
||||||
@click.argument("window_id", type=int)
|
@click.argument("window_id", type=int)
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def windows_rename(window_id, name):
|
def windows_rename(window_id, name):
|
||||||
"""Give a window a local alias NAME (stored in native host)."""
|
"""Give a window a local alias NAME (stored in native host)."""
|
||||||
client_from_ctx().windows.rename(window_id, name)
|
client_from_ctx().windows.rename(window_id, name)
|
||||||
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
|
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
|
||||||
|
|
||||||
@windows_group.command("close")
|
@windows_group.command("close")
|
||||||
@click.argument("window_id", type=int)
|
@click.argument("window_id", type=int)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def windows_close(window_id):
|
def windows_close(window_id):
|
||||||
"""Close a browser window."""
|
"""Close a browser window."""
|
||||||
client_from_ctx().windows.close(window_id)
|
client_from_ctx().windows.close(window_id)
|
||||||
console.print(f"[green]Window {window_id} closed[/green]")
|
console.print(f"[green]Window {window_id} closed[/green]")
|
||||||
|
|
||||||
@windows_group.command("open")
|
@windows_group.command("open")
|
||||||
@click.argument("url", required=False)
|
@click.argument("url", required=False)
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def windows_open(url):
|
def windows_open(url):
|
||||||
"""Open a new browser window."""
|
"""Open a new browser window."""
|
||||||
result = client_from_ctx().windows.open(url)
|
result = client_from_ctx().windows.open(url)
|
||||||
wid = result.get("id") if isinstance(result, dict) else result
|
wid = result.get("id") if isinstance(result, dict) else result
|
||||||
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
|
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from browser_cli.commands import client_from_ctx, handle_errors
|
||||||
|
from browser_cli.constants import CONFIG_DIR
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
WORKSPACES_PATH = CONFIG_DIR / "workspaces.json"
|
||||||
|
|
||||||
|
def _load() -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(WORKSPACES_PATH.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save(data: dict) -> None:
|
||||||
|
WORKSPACES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
WORKSPACES_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
@click.group("workspace")
|
||||||
|
def workspace_group():
|
||||||
|
"""Named browser workspaces built on top of sessions."""
|
||||||
|
|
||||||
|
@workspace_group.command("save")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--session", "session_name", default=None, help="Session name to save/use (default: workspace name)")
|
||||||
|
@click.option("--profile", default=None, help="Performance profile to remember")
|
||||||
|
@handle_errors
|
||||||
|
def workspace_save(name, session_name, profile):
|
||||||
|
session_name = session_name or name
|
||||||
|
result = client_from_ctx().session.save(session_name)
|
||||||
|
data = _load()
|
||||||
|
data[name] = {"session": session_name, "profile": profile}
|
||||||
|
_save(data)
|
||||||
|
console.print(f"[green]Workspace '{name}' saved[/green] ({result.get('tabs', 0) if isinstance(result, dict) else 0} tabs)")
|
||||||
|
|
||||||
|
@workspace_group.command("load")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--lazy", is_flag=True, help="Lazy-restore tabs")
|
||||||
|
@click.option("--eager-tabs", type=int, default=10, show_default=True)
|
||||||
|
@handle_errors
|
||||||
|
def workspace_load(name, lazy, eager_tabs):
|
||||||
|
data = _load()
|
||||||
|
ws = data.get(name)
|
||||||
|
if not ws:
|
||||||
|
raise click.ClickException(f"Workspace '{name}' not found")
|
||||||
|
client = client_from_ctx()
|
||||||
|
if ws.get("profile"):
|
||||||
|
client.perf.set_profile(ws["profile"])
|
||||||
|
result = client.session.load(ws["session"], lazy=lazy, eager_tabs=eager_tabs)
|
||||||
|
console.print(f"[green]Workspace '{name}' loaded[/green] ({result.get('tabs', 0) if isinstance(result, dict) else 0} tabs)")
|
||||||
|
|
||||||
|
@workspace_group.command("switch")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--lazy", is_flag=True)
|
||||||
|
@handle_errors
|
||||||
|
def workspace_switch(name, lazy):
|
||||||
|
"""Load a workspace. Alias for workspace load."""
|
||||||
|
ctx = click.get_current_context()
|
||||||
|
ctx.invoke(workspace_load, name=name, lazy=lazy, eager_tabs=10)
|
||||||
|
|
||||||
|
@workspace_group.command("list")
|
||||||
|
def workspace_list():
|
||||||
|
"""List configured workspaces."""
|
||||||
|
data = _load()
|
||||||
|
if not data:
|
||||||
|
console.print("[yellow]No workspaces[/yellow]")
|
||||||
|
return
|
||||||
|
table = Table(show_header=True, header_style="bold cyan")
|
||||||
|
table.add_column("Name")
|
||||||
|
table.add_column("Session")
|
||||||
|
table.add_column("Profile")
|
||||||
|
for name, ws in sorted(data.items()):
|
||||||
|
table.add_row(name, ws.get("session", ""), ws.get("profile") or "")
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
@workspace_group.command("remove")
|
||||||
|
@click.argument("name")
|
||||||
|
def workspace_remove(name):
|
||||||
|
data = _load()
|
||||||
|
if name not in data:
|
||||||
|
raise click.ClickException(f"Workspace '{name}' not found")
|
||||||
|
del data[name]
|
||||||
|
_save(data)
|
||||||
|
console.print(f"[green]Workspace '{name}' removed[/green]")
|
||||||
@@ -9,12 +9,16 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
APP_NAME = "browser-cli"
|
APP_NAME = "browser-cli"
|
||||||
|
PYPI_PACKAGE_NAME = "real-browser-cli"
|
||||||
RUNTIME_DIRNAME = ".browser_cli"
|
RUNTIME_DIRNAME = ".browser_cli"
|
||||||
DEFAULT_ALIAS = "default"
|
DEFAULT_ALIAS = "default"
|
||||||
|
|
||||||
NATIVE_HOST_NAME = "com.browsercli.host"
|
NATIVE_HOST_NAME = "com.browsercli.host"
|
||||||
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
|
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
|
||||||
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi"]
|
WEBSTORE_EXTENSION_ID = "hekaebjhbhhdbmakimmaklbblbmccahp"
|
||||||
|
FIREFOX_EXTENSION_ID = "browser-cli@yiprawr.dev"
|
||||||
|
ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID]
|
||||||
|
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi", "firefox"]
|
||||||
|
|
||||||
PROTOCOL_MIN_CLIENT = "0.9.0"
|
PROTOCOL_MIN_CLIENT = "0.9.0"
|
||||||
MAX_MSG_BYTES = 32 * 1024 * 1024
|
MAX_MSG_BYTES = 32 * 1024 * 1024
|
||||||
@@ -39,7 +43,6 @@ PAGEABLE_COMMANDS = {
|
|||||||
"extract.links",
|
"extract.links",
|
||||||
"extract.images",
|
"extract.images",
|
||||||
"extract.json",
|
"extract.json",
|
||||||
"cookies.list",
|
|
||||||
"session.list",
|
"session.list",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +67,10 @@ NATIVE_HOST_DIRS = {
|
|||||||
"linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"],
|
"linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"],
|
||||||
"darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"],
|
"darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"],
|
||||||
},
|
},
|
||||||
|
"firefox": {
|
||||||
|
"linux": [Path.home() / ".mozilla/native-messaging-hosts"],
|
||||||
|
"darwin": [Path.home() / "Library/Application Support/Mozilla/NativeMessagingHosts"],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
|
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
|
||||||
@@ -72,6 +79,7 @@ WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
|
|||||||
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
|
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
|
||||||
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
|
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
|
||||||
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
|
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
|
||||||
|
"firefox": [r"Software\Mozilla\NativeMessagingHosts"],
|
||||||
}
|
}
|
||||||
|
|
||||||
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / APP_NAME
|
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / APP_NAME
|
||||||
|
|||||||
@@ -4,26 +4,6 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
def _normalize_text(value):
|
|
||||||
return re.sub(r"\s+", " ", value or "").strip()
|
|
||||||
|
|
||||||
def _normalize_inline(value):
|
|
||||||
value = value.replace("\xa0", " ")
|
|
||||||
value = re.sub(r"[ \t\r\f\v]+", " ", value)
|
|
||||||
value = re.sub(r" *\n *", "\n", value)
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
def _collapse_blank_lines(value):
|
|
||||||
value = re.sub(r"[ \t]+\n", "\n", value)
|
|
||||||
value = re.sub(r"\n{3,}", "\n\n", value)
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
def _escape_markdown(text):
|
|
||||||
return re.sub(r"([\\`[\]])", r"\\\1", text)
|
|
||||||
|
|
||||||
def _escape_table_cell(text):
|
|
||||||
return text.replace("|", r"\|").replace("\n", " ").strip()
|
|
||||||
|
|
||||||
class _HtmlNode:
|
class _HtmlNode:
|
||||||
def __init__(self, tag=None, attrs=None, text=None):
|
def __init__(self, tag=None, attrs=None, text=None):
|
||||||
self.tag = tag
|
self.tag = tag
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ class Tab:
|
|||||||
title: str = ""
|
title: str = ""
|
||||||
url: str = ""
|
url: str = ""
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
|
index: int = 0
|
||||||
browser: str | None = None
|
browser: str | None = None
|
||||||
|
browser_name: str | None = None
|
||||||
|
browser_group: str | None = None
|
||||||
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
|
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
|
||||||
|
|
||||||
def _b(self) -> BoundBrowser:
|
def _b(self) -> BoundBrowser:
|
||||||
@@ -149,7 +152,10 @@ class Group:
|
|||||||
color: str
|
color: str
|
||||||
collapsed: bool
|
collapsed: bool
|
||||||
tab_count: int
|
tab_count: int
|
||||||
|
window_id: int | None = None
|
||||||
browser: str | None = None
|
browser: str | None = None
|
||||||
|
browser_name: str | None = None
|
||||||
|
browser_group: str | None = None
|
||||||
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
|
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
|
||||||
|
|
||||||
def _b(self) -> BoundBrowser:
|
def _b(self) -> BoundBrowser:
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Challenge/response auth helpers for remote TCP transport."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from browser_cli.errors import BrowserNotConnected
|
||||||
|
from browser_cli.version_manager import USER_AGENT
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"}
|
||||||
|
PQ_WARNING = (
|
||||||
|
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
||||||
|
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
|
||||||
|
try:
|
||||||
|
challenge = json.loads(raw)
|
||||||
|
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
|
||||||
|
return challenge, nonce_hex
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def check_min_client_version(challenge: dict | None) -> None:
|
||||||
|
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
||||||
|
if not min_ver:
|
||||||
|
return
|
||||||
|
from browser_cli.version_manager import parse_version
|
||||||
|
try:
|
||||||
|
client_ver = USER_AGENT.split("/", 1)[1]
|
||||||
|
if parse_version(client_ver) < parse_version(min_ver):
|
||||||
|
raise BrowserNotConnected(
|
||||||
|
f"Client version {client_ver} is too old for this server "
|
||||||
|
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
|
||||||
|
)
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clean_message(msg: dict) -> dict:
|
||||||
|
return {key: value for key, value in msg.items() if key not in AUTH_FIELDS}
|
||||||
|
|
||||||
|
def get_pq_public_key(challenge: dict | None) -> str | None:
|
||||||
|
if not isinstance(challenge, dict):
|
||||||
|
return None
|
||||||
|
from browser_cli.auth import PQ_KEX_ALG
|
||||||
|
kex = challenge.get("pq_kex")
|
||||||
|
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
|
||||||
|
return str(kex["public_key"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
|
||||||
|
from browser_cli.auth import pq_encrypt, public_key_hex, sign
|
||||||
|
|
||||||
|
nonce = bytes.fromhex(nonce_hex)
|
||||||
|
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
|
||||||
|
pubkey = public_key_hex(private_key)
|
||||||
|
if pq_shared_secret is None:
|
||||||
|
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
|
||||||
|
|
||||||
|
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
|
||||||
|
return {
|
||||||
|
"id": clean_msg.get("id"),
|
||||||
|
"user_agent": clean_msg.get("user_agent"),
|
||||||
|
"pubkey": pubkey,
|
||||||
|
"sig": sig.hex(),
|
||||||
|
"pq_kex": clean_msg["pq_kex"],
|
||||||
|
"encrypted": encrypted,
|
||||||
|
}
|
||||||
|
|
||||||
|
def emit_no_pq_warning(enabled: bool) -> None:
|
||||||
|
if enabled:
|
||||||
|
sys.stderr.write(PQ_WARNING)
|
||||||
|
|
||||||
|
def build_auth_message(
|
||||||
|
msg: dict,
|
||||||
|
challenge: dict | None,
|
||||||
|
nonce_hex: str | None,
|
||||||
|
private_key,
|
||||||
|
encapsulate: Callable[[str], tuple[str, bytes]],
|
||||||
|
*,
|
||||||
|
warn_no_pq: bool = True,
|
||||||
|
) -> tuple[dict, bytes | None]:
|
||||||
|
if not nonce_hex or private_key is None:
|
||||||
|
emit_no_pq_warning(warn_no_pq)
|
||||||
|
return msg, None
|
||||||
|
|
||||||
|
clean_msg = clean_message(msg)
|
||||||
|
pq_shared_secret = None
|
||||||
|
pq_public_key = get_pq_public_key(challenge)
|
||||||
|
if pq_public_key:
|
||||||
|
from browser_cli.auth import PQ_KEX_ALG
|
||||||
|
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
|
||||||
|
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
|
||||||
|
else:
|
||||||
|
emit_no_pq_warning(warn_no_pq)
|
||||||
|
|
||||||
|
return signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
|
||||||
|
|
||||||
|
async def build_auth_message_async(
|
||||||
|
msg: dict,
|
||||||
|
challenge: dict | None,
|
||||||
|
nonce_hex: str | None,
|
||||||
|
private_key,
|
||||||
|
*,
|
||||||
|
warn_no_pq: bool = True,
|
||||||
|
) -> tuple[dict, bytes | None]:
|
||||||
|
def encapsulate(public_key: str) -> tuple[str, bytes]:
|
||||||
|
from browser_cli.auth import pq_kex_client_encapsulate
|
||||||
|
return pq_kex_client_encapsulate(public_key)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
build_auth_message,
|
||||||
|
msg,
|
||||||
|
challenge,
|
||||||
|
nonce_hex,
|
||||||
|
private_key,
|
||||||
|
encapsulate,
|
||||||
|
warn_no_pq=warn_no_pq,
|
||||||
|
)
|
||||||
|
|
||||||
|
def decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
|
||||||
|
if response is None or pq_shared_secret is None:
|
||||||
|
return response
|
||||||
|
try:
|
||||||
|
from browser_cli.auth import pq_decrypt
|
||||||
|
envelope = json.loads(response)
|
||||||
|
if isinstance(envelope, dict) and "encrypted" in envelope:
|
||||||
|
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
|
||||||
|
except Exception as exc:
|
||||||
|
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {exc}") from exc
|
||||||
|
return response
|
||||||
|
|
||||||
|
def with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
|
||||||
|
if challenge_raw is None:
|
||||||
|
raise BrowserNotConnected("No challenge received from remote endpoint")
|
||||||
|
challenge, nonce_hex = parse_challenge(challenge_raw)
|
||||||
|
check_min_client_version(challenge)
|
||||||
|
return build_auth(msg, challenge, nonce_hex, private_key)
|
||||||
|
|
||||||
|
def should_warn_no_pq(msg: dict) -> bool:
|
||||||
|
return not bool(msg.pop("_suppress_pq_warning", False))
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Socket helpers for remote TCP/TLS transport."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from browser_cli.endpoints import _resolve_connect_endpoint
|
||||||
|
from browser_cli.framing import async_recv_exact, async_recv_frame, recv_exact, recv_frame
|
||||||
|
|
||||||
|
def recv_exact_bytes(sock: socket.socket, n: int) -> bytes:
|
||||||
|
return recv_exact(sock, n) or b""
|
||||||
|
|
||||||
|
def recv_all(sock: socket.socket) -> bytes:
|
||||||
|
return recv_frame(sock, label="Response") or b""
|
||||||
|
|
||||||
|
async def async_recv_exact_bytes(reader: asyncio.StreamReader, n: int) -> bytes:
|
||||||
|
return await async_recv_exact(reader, n) or b""
|
||||||
|
|
||||||
|
async def async_recv_all(reader: asyncio.StreamReader) -> bytes:
|
||||||
|
return await async_recv_frame(reader, label="Response") or b""
|
||||||
|
|
||||||
|
def split_endpoint(endpoint: str) -> tuple[str, int]:
|
||||||
|
connect_ep = _resolve_connect_endpoint(endpoint)
|
||||||
|
host, _, port_str = connect_ep.rpartition(":")
|
||||||
|
return host, int(port_str)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def open_socket(endpoint: str):
|
||||||
|
host, port = split_endpoint(endpoint)
|
||||||
|
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
raw_sock.settimeout(30)
|
||||||
|
try:
|
||||||
|
raw_sock.connect((host, port))
|
||||||
|
if port == 443:
|
||||||
|
import ssl
|
||||||
|
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
|
||||||
|
else:
|
||||||
|
sock = raw_sock
|
||||||
|
except Exception:
|
||||||
|
raw_sock.close()
|
||||||
|
raise
|
||||||
|
with sock:
|
||||||
|
yield sock
|
||||||
|
|
||||||
|
async def open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||||
|
host, port = split_endpoint(endpoint)
|
||||||
|
ssl_ctx = None
|
||||||
|
if port == 443:
|
||||||
|
import ssl
|
||||||
|
ssl_ctx = ssl.create_default_context()
|
||||||
|
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
|
||||||
@@ -1,202 +1,43 @@
|
|||||||
"""TCP/TLS transport for talking to a remote ``browser-cli serve``.
|
"""TCP/TLS transport for talking to a remote ``browser-cli serve``.
|
||||||
|
|
||||||
Owns the wire mechanics of the remote leg: open a socket (TLS on :443),
|
This module keeps the public/private compatibility surface used by older tests
|
||||||
complete the signed challenge/response handshake with an optional post-quantum
|
and callers, while delegating socket mechanics and auth-handshake details to
|
||||||
key exchange, frame the request, and read the framed (possibly encrypted)
|
focused helper modules.
|
||||||
response. The higher-level "which endpoint / which profile / which key"
|
|
||||||
decisions stay in :mod:`browser_cli.client.core`.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from typing import TypeVar
|
|
||||||
|
|
||||||
from browser_cli.errors import BrowserNotConnected
|
from browser_cli.framing import async_send_frame, frame
|
||||||
from browser_cli.endpoints import _resolve_connect_endpoint
|
from browser_cli.remote.auth import (
|
||||||
from browser_cli.framing import async_recv_exact, async_recv_frame, async_send_frame, frame, recv_exact, recv_frame
|
build_auth_message as _build_auth_message,
|
||||||
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
|
build_auth_message_async as _build_auth_message_async,
|
||||||
|
decode_pq_response as _decode_pq_response,
|
||||||
T = TypeVar("T")
|
parse_challenge as _parse_challenge,
|
||||||
_AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"}
|
should_warn_no_pq as _should_warn_no_pq,
|
||||||
_PQ_WARNING = (
|
with_challenge as _with_challenge,
|
||||||
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
)
|
||||||
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
from browser_cli.remote.socket import (
|
||||||
|
async_recv_all as _async_recv_all,
|
||||||
|
async_recv_exact_bytes as _async_recv_exact,
|
||||||
|
open_async_connection as _open_async_connection,
|
||||||
|
open_socket as _open_socket,
|
||||||
|
recv_all as _recv_all,
|
||||||
|
recv_exact_bytes as _recv_exact,
|
||||||
|
split_endpoint as _split_endpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||||
return recv_exact(sock, n) or b""
|
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
||||||
|
|
||||||
def _recv_all(sock: socket.socket) -> bytes:
|
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
||||||
return recv_frame(sock, label="Response") or b""
|
|
||||||
|
|
||||||
async def _async_recv_exact(reader: asyncio.StreamReader, n: int) -> bytes:
|
|
||||||
return await async_recv_exact(reader, n) or b""
|
|
||||||
|
|
||||||
async def _async_recv_all(reader: asyncio.StreamReader) -> bytes:
|
|
||||||
return await async_recv_frame(reader, label="Response") or b""
|
|
||||||
|
|
||||||
def _split_endpoint(endpoint: str) -> tuple[str, int]:
|
|
||||||
connect_ep = _resolve_connect_endpoint(endpoint)
|
|
||||||
host, _, port_str = connect_ep.rpartition(":")
|
|
||||||
return host, int(port_str)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _open_socket(endpoint: str):
|
|
||||||
host, port = _split_endpoint(endpoint)
|
|
||||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
raw_sock.settimeout(30)
|
|
||||||
try:
|
|
||||||
raw_sock.connect((host, port))
|
|
||||||
if port == 443:
|
|
||||||
import ssl
|
|
||||||
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
|
|
||||||
else:
|
|
||||||
sock = raw_sock
|
|
||||||
except Exception:
|
|
||||||
raw_sock.close()
|
|
||||||
raise
|
|
||||||
with sock:
|
|
||||||
yield sock
|
|
||||||
|
|
||||||
async def _open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
||||||
host, port = _split_endpoint(endpoint)
|
|
||||||
ssl_ctx = None
|
|
||||||
if port == 443:
|
|
||||||
import ssl
|
|
||||||
ssl_ctx = ssl.create_default_context()
|
|
||||||
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
|
|
||||||
|
|
||||||
def _parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
|
|
||||||
try:
|
|
||||||
challenge = json.loads(raw)
|
|
||||||
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
|
|
||||||
return challenge, nonce_hex
|
|
||||||
except (json.JSONDecodeError, AttributeError):
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def _check_min_client_version(challenge: dict | None) -> None:
|
|
||||||
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
|
||||||
if not min_ver:
|
|
||||||
return
|
|
||||||
from browser_cli.version_manager import parse_version
|
|
||||||
try:
|
|
||||||
client_ver = _USER_AGENT.split("/", 1)[1]
|
|
||||||
if parse_version(client_ver) < parse_version(min_ver):
|
|
||||||
raise BrowserNotConnected(
|
|
||||||
f"Client version {client_ver} is too old for this server "
|
|
||||||
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
|
|
||||||
)
|
|
||||||
except (IndexError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _clean_message(msg: dict) -> dict:
|
|
||||||
return {k: v for k, v in msg.items() if k not in _AUTH_FIELDS}
|
|
||||||
|
|
||||||
def _get_pq_public_key(challenge: dict | None) -> str | None:
|
|
||||||
if not isinstance(challenge, dict):
|
|
||||||
return None
|
|
||||||
from browser_cli.auth import PQ_KEX_ALG
|
|
||||||
kex = challenge.get("pq_kex")
|
|
||||||
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
|
|
||||||
return str(kex["public_key"])
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
|
|
||||||
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, public_key_hex, sign
|
|
||||||
|
|
||||||
nonce = bytes.fromhex(nonce_hex)
|
|
||||||
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
|
|
||||||
pubkey = public_key_hex(private_key)
|
|
||||||
if pq_shared_secret is None:
|
|
||||||
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
|
|
||||||
|
|
||||||
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
|
|
||||||
return {
|
|
||||||
"id": clean_msg.get("id"),
|
|
||||||
"user_agent": clean_msg.get("user_agent"),
|
|
||||||
"pubkey": pubkey,
|
|
||||||
"sig": sig.hex(),
|
|
||||||
"pq_kex": clean_msg["pq_kex"],
|
|
||||||
"encrypted": encrypted,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _warn_no_pq(enabled: bool) -> None:
|
|
||||||
if enabled:
|
|
||||||
sys.stderr.write(_PQ_WARNING)
|
|
||||||
|
|
||||||
def _build_auth_message(
|
|
||||||
msg: dict,
|
|
||||||
challenge: dict | None,
|
|
||||||
nonce_hex: str | None,
|
|
||||||
private_key,
|
|
||||||
encapsulate: Callable[[str], tuple[str, bytes]],
|
|
||||||
*,
|
|
||||||
warn_no_pq: bool = True,
|
|
||||||
) -> tuple[dict, bytes | None]:
|
|
||||||
if not nonce_hex or private_key is None:
|
|
||||||
_warn_no_pq(warn_no_pq)
|
|
||||||
return msg, None
|
|
||||||
|
|
||||||
clean_msg = _clean_message(msg)
|
|
||||||
pq_shared_secret = None
|
|
||||||
pq_public_key = _get_pq_public_key(challenge)
|
|
||||||
if pq_public_key:
|
|
||||||
from browser_cli.auth import PQ_KEX_ALG
|
|
||||||
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
|
|
||||||
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
|
|
||||||
else:
|
|
||||||
_warn_no_pq(warn_no_pq)
|
|
||||||
|
|
||||||
return _signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
|
|
||||||
|
|
||||||
async def _build_auth_message_async(
|
|
||||||
msg: dict,
|
|
||||||
challenge: dict | None,
|
|
||||||
nonce_hex: str | None,
|
|
||||||
private_key,
|
|
||||||
*,
|
|
||||||
warn_no_pq: bool = True,
|
|
||||||
) -> tuple[dict, bytes | None]:
|
|
||||||
def encapsulate(public_key: str) -> tuple[str, bytes]:
|
|
||||||
from browser_cli.auth import pq_kex_client_encapsulate
|
from browser_cli.auth import pq_kex_client_encapsulate
|
||||||
return pq_kex_client_encapsulate(public_key)
|
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
|
||||||
|
|
||||||
return await asyncio.to_thread(
|
with _open_socket(endpoint) as sock:
|
||||||
_build_auth_message,
|
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
||||||
msg,
|
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
||||||
challenge,
|
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||||
nonce_hex,
|
|
||||||
private_key,
|
|
||||||
encapsulate,
|
|
||||||
warn_no_pq=warn_no_pq,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
|
|
||||||
if response is None or pq_shared_secret is None:
|
|
||||||
return response
|
|
||||||
try:
|
|
||||||
from browser_cli.auth import pq_decrypt
|
|
||||||
envelope = json.loads(response)
|
|
||||||
if isinstance(envelope, dict) and "encrypted" in envelope:
|
|
||||||
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
|
|
||||||
except Exception as e:
|
|
||||||
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
|
|
||||||
return response
|
|
||||||
|
|
||||||
def _with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
|
|
||||||
if challenge_raw is None:
|
|
||||||
raise BrowserNotConnected("No challenge received from remote endpoint")
|
|
||||||
challenge, nonce_hex = _parse_challenge(challenge_raw)
|
|
||||||
_check_min_client_version(challenge)
|
|
||||||
return build_auth(msg, challenge, nonce_hex, private_key)
|
|
||||||
|
|
||||||
def _should_warn_no_pq(msg: dict) -> bool:
|
|
||||||
return not bool(msg.pop("_suppress_pq_warning", False))
|
|
||||||
|
|
||||||
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||||
reader, writer = await _open_async_connection(endpoint)
|
reader, writer = await _open_async_connection(endpoint)
|
||||||
@@ -216,15 +57,3 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
|
|||||||
await writer.wait_closed()
|
await writer.wait_closed()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
|
||||||
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
|
||||||
|
|
||||||
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
|
||||||
from browser_cli.auth import pq_kex_client_encapsulate
|
|
||||||
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
|
|
||||||
|
|
||||||
with _open_socket(endpoint) as sock:
|
|
||||||
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
|
||||||
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
|
||||||
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Each namespace groups related browser commands under a short accessor on the
|
|||||||
client (``b.tabs``, ``b.dom``, ``b.session``, ...), mirroring the command groups
|
client (``b.tabs``, ``b.dom``, ``b.session``, ...), mirroring the command groups
|
||||||
in the browser extension.
|
in the browser extension.
|
||||||
"""
|
"""
|
||||||
from browser_cli.sdk.browser_data import CookiesNS, StorageNS
|
from browser_cli.sdk.browser_data import StorageNS
|
||||||
from browser_cli.sdk.decorators import DecoratorsNS
|
from browser_cli.sdk.decorators import DecoratorsNS
|
||||||
from browser_cli.sdk.dom import DomNS, ExtractNS, PageNS
|
from browser_cli.sdk.dom import DomNS, ExtractNS, PageNS
|
||||||
from browser_cli.sdk.extension import ExtensionNS
|
from browser_cli.sdk.extension import ExtensionNS
|
||||||
@@ -24,7 +24,6 @@ NAMESPACE_SPECS = (
|
|||||||
("extract", ExtractNS),
|
("extract", ExtractNS),
|
||||||
("page", PageNS),
|
("page", PageNS),
|
||||||
("storage", StorageNS),
|
("storage", StorageNS),
|
||||||
("cookies", CookiesNS),
|
|
||||||
("session", SessionNS),
|
("session", SessionNS),
|
||||||
("perf", PerfNS),
|
("perf", PerfNS),
|
||||||
("extension", ExtensionNS),
|
("extension", ExtensionNS),
|
||||||
@@ -40,7 +39,6 @@ __all__ = [
|
|||||||
"ExtractNS",
|
"ExtractNS",
|
||||||
"PageNS",
|
"PageNS",
|
||||||
"StorageNS",
|
"StorageNS",
|
||||||
"CookiesNS",
|
|
||||||
"SessionNS",
|
"SessionNS",
|
||||||
"PerfNS",
|
"PerfNS",
|
||||||
"ExtensionNS",
|
"ExtensionNS",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar, cast
|
||||||
|
|
||||||
F = TypeVar("F", bound=Callable)
|
F = TypeVar("F", bound=Callable)
|
||||||
_MISSING = object()
|
_MISSING = object()
|
||||||
@@ -54,8 +54,8 @@ def sdk_command(
|
|||||||
return _clone_default(default)
|
return _clone_default(default)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
wrapper._browser_cli_command = name # type: ignore[attr-defined]
|
setattr(wrapper, "_browser_cli_command", name)
|
||||||
return wrapper # type: ignore[return-value]
|
return cast(F, wrapper)
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Storage and cookies namespaces: ``b.storage.*``, ``b.cookies.*``."""
|
"""Storage namespace: ``b.storage.*``."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from browser_cli.sdk.base import Namespace, sdk_command
|
from browser_cli.sdk.base import Namespace, sdk_command
|
||||||
@@ -35,51 +35,3 @@ class StorageNS(Namespace):
|
|||||||
tab_id: int | None = None,
|
tab_id: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set a localStorage/sessionStorage entry."""
|
"""Set a localStorage/sessionStorage entry."""
|
||||||
|
|
||||||
class CookiesNS(Namespace):
|
|
||||||
"""List, get, and set cookies."""
|
|
||||||
|
|
||||||
@sdk_command("cookies.list", lambda self, *, url=None, domain=None, name=None: {
|
|
||||||
"url": url,
|
|
||||||
"domain": domain,
|
|
||||||
"name": name,
|
|
||||||
}, default=[])
|
|
||||||
def list(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
url: str | None = None,
|
|
||||||
domain: str | None = None,
|
|
||||||
name: str | None = None,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""List cookies, optionally filtered by url, domain, or name."""
|
|
||||||
|
|
||||||
@sdk_command("cookies.get", lambda self, url, name: {"url": url, "name": name})
|
|
||||||
def get(self, url: str, name: str) -> dict | None:
|
|
||||||
"""Get a single cookie by url and name."""
|
|
||||||
|
|
||||||
@sdk_command("cookies.set", lambda self, url, name, value, *, domain=None, path=None, secure=None,
|
|
||||||
http_only=None, expiration_date=None, same_site=None: {
|
|
||||||
"url": url,
|
|
||||||
"name": name,
|
|
||||||
"value": value,
|
|
||||||
"domain": domain,
|
|
||||||
"path": path,
|
|
||||||
"secure": secure,
|
|
||||||
"httpOnly": http_only,
|
|
||||||
"expirationDate": expiration_date,
|
|
||||||
"sameSite": same_site,
|
|
||||||
})
|
|
||||||
def set(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
name: str,
|
|
||||||
value: str,
|
|
||||||
*,
|
|
||||||
domain: str | None = None,
|
|
||||||
path: str | None = None,
|
|
||||||
secure: bool | None = None,
|
|
||||||
http_only: bool | None = None,
|
|
||||||
expiration_date: float | None = None,
|
|
||||||
same_site: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Set a cookie. Returns the created cookie dict."""
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import asyncio
|
|||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import TypeVar
|
from typing import TypeVar, cast
|
||||||
|
|
||||||
from browser_cli.sdk.base import Namespace
|
from browser_cli.sdk.base import Namespace
|
||||||
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
|
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
|
||||||
@@ -53,7 +53,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
|
|||||||
finally:
|
finally:
|
||||||
if cleanup is not None:
|
if cleanup is not None:
|
||||||
await asyncio.to_thread(cleanup, value)
|
await asyncio.to_thread(cleanup, value)
|
||||||
return async_wrapper # type: ignore[return-value]
|
return cast(F, async_wrapper)
|
||||||
return WorkflowDecoratorsMixin._value_decorator(
|
return WorkflowDecoratorsMixin._value_decorator(
|
||||||
self, fn, get_value, keyword=keyword, cleanup=cleanup
|
self, fn, get_value, keyword=keyword, cleanup=cleanup
|
||||||
)
|
)
|
||||||
@@ -74,7 +74,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
|
|||||||
finally:
|
finally:
|
||||||
if previous:
|
if previous:
|
||||||
await asyncio.to_thread(self._c.perf.set_profile, previous)
|
await asyncio.to_thread(self._c.perf.set_profile, previous)
|
||||||
return async_wrapper # type: ignore[return-value]
|
return cast(F, async_wrapper)
|
||||||
return WorkflowDecoratorsMixin.performance_profile(self, profile, restore=restore)(fn)
|
return WorkflowDecoratorsMixin.performance_profile(self, profile, restore=restore)(fn)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
|
|||||||
raise
|
raise
|
||||||
if delay > 0:
|
if delay > 0:
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
raise last_error # type: ignore[misc]
|
raise cast(BaseException, last_error)
|
||||||
return async_wrapper # type: ignore[return-value]
|
return cast(F, async_wrapper)
|
||||||
return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn)
|
return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn)
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ from browser_cli.sdk.base import Namespace, sdk_command
|
|||||||
class ExtensionNS(Namespace):
|
class ExtensionNS(Namespace):
|
||||||
"""Control the browser-cli extension itself."""
|
"""Control the browser-cli extension itself."""
|
||||||
|
|
||||||
|
@sdk_command("extension.info", default={})
|
||||||
|
def info(self) -> dict:
|
||||||
|
"""Return extension version, runtime metadata, and capabilities."""
|
||||||
|
|
||||||
|
@sdk_command("extension.capabilities", default=[])
|
||||||
|
def capabilities(self) -> list[str]:
|
||||||
|
"""Return feature capability strings advertised by the extension."""
|
||||||
|
|
||||||
@sdk_command("extension.reload")
|
@sdk_command("extension.reload")
|
||||||
def reload(self) -> None:
|
def reload(self) -> None:
|
||||||
"""Reload the browser-cli extension service worker.
|
"""Reload the browser-cli extension service worker.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class FactoryMixin:
|
|||||||
browser_profile: str | None = None,
|
browser_profile: str | None = None,
|
||||||
browser_name: str | None = None,
|
browser_name: str | None = None,
|
||||||
browser_remote: str | None = None,
|
browser_remote: str | None = None,
|
||||||
|
browser_type: str | None = None,
|
||||||
|
browser_group: str | None = None,
|
||||||
) -> Tab:
|
) -> Tab:
|
||||||
tab = Tab(
|
tab = Tab(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
@@ -37,7 +39,10 @@ class FactoryMixin:
|
|||||||
title=data.get("title") or "",
|
title=data.get("title") or "",
|
||||||
url=data.get("url") or "",
|
url=data.get("url") or "",
|
||||||
group_id=data.get("groupId") or None,
|
group_id=data.get("groupId") or None,
|
||||||
|
index=data.get("index", 0) or 0,
|
||||||
browser=browser_name,
|
browser=browser_name,
|
||||||
|
browser_name=browser_type,
|
||||||
|
browser_group=browser_group,
|
||||||
)
|
)
|
||||||
client = cast(_FactoryClient, self)
|
client = cast(_FactoryClient, self)
|
||||||
tab._browser = self if browser_profile is None else cast(Any, type(self))(
|
tab._browser = self if browser_profile is None else cast(Any, type(self))(
|
||||||
@@ -61,6 +66,8 @@ class FactoryMixin:
|
|||||||
browser_profile: str | None = None,
|
browser_profile: str | None = None,
|
||||||
browser_name: str | None = None,
|
browser_name: str | None = None,
|
||||||
browser_remote: str | None = None,
|
browser_remote: str | None = None,
|
||||||
|
browser_type: str | None = None,
|
||||||
|
browser_group: str | None = None,
|
||||||
) -> Group:
|
) -> Group:
|
||||||
group = Group(
|
group = Group(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
@@ -68,7 +75,10 @@ class FactoryMixin:
|
|||||||
color=data.get("color") or "",
|
color=data.get("color") or "",
|
||||||
collapsed=data.get("collapsed", False),
|
collapsed=data.get("collapsed", False),
|
||||||
tab_count=data.get("tabCount", 0),
|
tab_count=data.get("tabCount", 0),
|
||||||
|
window_id=data.get("windowId"),
|
||||||
browser=browser_name,
|
browser=browser_name,
|
||||||
|
browser_name=browser_type,
|
||||||
|
browser_group=browser_group,
|
||||||
)
|
)
|
||||||
client = cast(_FactoryClient, self)
|
client = cast(_FactoryClient, self)
|
||||||
group._browser = self if browser_profile is None else cast(Any, type(self))(
|
group._browser = self if browser_profile is None else cast(Any, type(self))(
|
||||||
@@ -86,6 +96,8 @@ class FactoryMixin:
|
|||||||
browser_profile=target.profile if target else None,
|
browser_profile=target.profile if target else None,
|
||||||
browser_name=target.display_name if target else None,
|
browser_name=target.display_name if target else None,
|
||||||
browser_remote=target.remote if target else None,
|
browser_remote=target.remote if target else None,
|
||||||
|
browser_type=getattr(target, "browser_name", None) if target else None,
|
||||||
|
browser_group=getattr(target, "display_group", None) if target else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def group_from_target(self, data: dict, target) -> Group:
|
def group_from_target(self, data: dict, target) -> Group:
|
||||||
@@ -95,6 +107,8 @@ class FactoryMixin:
|
|||||||
browser_profile=target.profile if target else None,
|
browser_profile=target.profile if target else None,
|
||||||
browser_name=target.display_name if target else None,
|
browser_name=target.display_name if target else None,
|
||||||
browser_remote=target.remote if target else None,
|
browser_remote=target.remote if target else None,
|
||||||
|
browser_type=getattr(target, "browser_name", None) if target else None,
|
||||||
|
browser_group=getattr(target, "display_group", None) if target else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
|||||||
from browser_cli.models import Tab
|
from browser_cli.models import Tab
|
||||||
from browser_cli.sdk.base import Namespace, sdk_command
|
from browser_cli.sdk.base import Namespace, sdk_command
|
||||||
|
|
||||||
def _open_args(self, url, *, background=False, window=None, group=None):
|
def _open_args(self, url, *, background=False, focus=False, window=None, group=None, **_ignored):
|
||||||
return {"url": url, "background": background, "window": window, "group": group}
|
return {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}
|
||||||
|
|
||||||
def _tab_args(self, tab_id=None):
|
def _tab_args(self, tab_id=None):
|
||||||
return {"tabId": tab_id}
|
return {"tabId": tab_id}
|
||||||
@@ -13,9 +13,31 @@ def _tab_args(self, tab_id=None):
|
|||||||
class NavigationNS(Namespace):
|
class NavigationNS(Namespace):
|
||||||
"""Open URLs, navigate history, and focus tabs."""
|
"""Open URLs, navigate history, and focus tabs."""
|
||||||
|
|
||||||
@sdk_command("navigate.open", _open_args)
|
def open(
|
||||||
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
|
self,
|
||||||
"""Open *url* in a new tab."""
|
url: str,
|
||||||
|
*,
|
||||||
|
background: bool = False,
|
||||||
|
focus: bool = False,
|
||||||
|
window: str | None = None,
|
||||||
|
group: str | None = None,
|
||||||
|
reuse: bool = False,
|
||||||
|
reuse_domain: bool = False,
|
||||||
|
reuse_title: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Open *url* in a new tab without stealing OS focus by default.
|
||||||
|
|
||||||
|
``reuse``/``reuse_domain``/``reuse_title`` navigate an existing matching tab
|
||||||
|
instead of creating a new one.
|
||||||
|
"""
|
||||||
|
tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
||||||
|
if tab is not None:
|
||||||
|
self.to(tab.id, url)
|
||||||
|
if focus:
|
||||||
|
self._c.tabs.activate(tab.id)
|
||||||
|
return None
|
||||||
|
self.command("navigate.open", _open_args(self, url, background=background, focus=focus, window=window, group=group))
|
||||||
|
return None
|
||||||
|
|
||||||
def open_wait(
|
def open_wait(
|
||||||
self,
|
self,
|
||||||
@@ -23,14 +45,24 @@ class NavigationNS(Namespace):
|
|||||||
*,
|
*,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
background: bool = False,
|
background: bool = False,
|
||||||
|
focus: bool = False,
|
||||||
window: str | None = None,
|
window: str | None = None,
|
||||||
group: str | None = None,
|
group: str | None = None,
|
||||||
|
reuse: bool = False,
|
||||||
|
reuse_domain: bool = False,
|
||||||
|
reuse_title: str | None = None,
|
||||||
) -> Tab:
|
) -> Tab:
|
||||||
"""Open *url* in a new tab and block until fully loaded. Returns the Tab."""
|
"""Open *url* in a new tab and block until fully loaded. Returns the Tab."""
|
||||||
|
tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
||||||
|
if tab is not None:
|
||||||
|
self.to(tab.id, url)
|
||||||
|
if focus:
|
||||||
|
self._c.tabs.activate(tab.id)
|
||||||
|
return self._c.tabs.wait_for_load(tab.id, timeout=timeout)
|
||||||
return self.require_tab(
|
return self.require_tab(
|
||||||
self.command("navigate.open_wait", {
|
self.command("navigate.open_wait", {
|
||||||
"url": url, "timeout": int(timeout * 1000),
|
"url": url, "timeout": int(timeout * 1000),
|
||||||
"background": background, "window": window, "group": group,
|
"background": background or not focus, "focus": focus, "window": window, "group": group,
|
||||||
}),
|
}),
|
||||||
"navigate.open_wait returned unexpected data",
|
"navigate.open_wait returned unexpected data",
|
||||||
)
|
)
|
||||||
@@ -59,9 +91,26 @@ class NavigationNS(Namespace):
|
|||||||
def to(self, tab_id: int, url: str) -> None:
|
def to(self, tab_id: int, url: str) -> None:
|
||||||
"""Navigate a specific tab to *url* in place."""
|
"""Navigate a specific tab to *url* in place."""
|
||||||
|
|
||||||
|
def _reuse_target(self, url: str, *, reuse: bool, reuse_domain: bool, reuse_title: str | None):
|
||||||
|
if not (reuse or reuse_domain or reuse_title):
|
||||||
|
return None
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
wanted = urlparse(url)
|
||||||
|
wanted_host = wanted.netloc.lower()
|
||||||
|
for tab in self._c.tabs.list():
|
||||||
|
tab_url = tab.url or ""
|
||||||
|
parsed = urlparse(tab_url)
|
||||||
|
if reuse and tab_url == url:
|
||||||
|
return tab
|
||||||
|
if reuse_domain and wanted_host and parsed.netloc.lower() == wanted_host:
|
||||||
|
return tab
|
||||||
|
if reuse_title and reuse_title.lower() in (tab.title or "").lower():
|
||||||
|
return tab
|
||||||
|
return None
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self, engine: str, query: str, *,
|
self, engine: str, query: str, *,
|
||||||
background: bool = False, window: str | None = None, group: str | None = None,
|
background: bool = False, focus: bool = False, window: str | None = None, group: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
|
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
@@ -70,4 +119,4 @@ class NavigationNS(Namespace):
|
|||||||
if template is None:
|
if template is None:
|
||||||
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
|
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
|
||||||
url = template.format(query=quote_plus(query))
|
url = template.format(query=quote_plus(query))
|
||||||
self.command("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group})
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ _UNSET = object()
|
|||||||
def _browser_cli_package():
|
def _browser_cli_package():
|
||||||
return sys.modules.get("browser_cli") or importlib.import_module("browser_cli")
|
return sys.modules.get("browser_cli") or importlib.import_module("browser_cli")
|
||||||
|
|
||||||
|
def _with_profile_display(targets: list[BrowserTarget]) -> list[BrowserTarget]:
|
||||||
|
"""Use profile-only labels when a command is already scoped to one remote."""
|
||||||
|
return [
|
||||||
|
BrowserTarget(
|
||||||
|
profile=target.profile,
|
||||||
|
display_name=target.profile if target.remote else target.display_name,
|
||||||
|
socket_path=target.socket_path,
|
||||||
|
remote=target.remote,
|
||||||
|
browser_name=target.browser_name,
|
||||||
|
display_group=None,
|
||||||
|
)
|
||||||
|
for target in targets
|
||||||
|
]
|
||||||
|
|
||||||
class RoutingMixin:
|
class RoutingMixin:
|
||||||
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
|
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
|
||||||
|
|
||||||
@@ -51,10 +65,15 @@ class RoutingMixin:
|
|||||||
def _multi_browser_targets(self) -> list[BrowserTarget]:
|
def _multi_browser_targets(self) -> list[BrowserTarget]:
|
||||||
client = self._client
|
client = self._client
|
||||||
package = _browser_cli_package()
|
package = _browser_cli_package()
|
||||||
if client._browser is not None:
|
if client._browser is not None and not client._remote:
|
||||||
|
targets = package.remote_targets_for_alias(client._browser, key=client._key)
|
||||||
|
if len(targets) <= 1:
|
||||||
|
return []
|
||||||
|
targets = _with_profile_display(targets)
|
||||||
|
elif client._browser is not None:
|
||||||
return []
|
return []
|
||||||
if client._remote:
|
elif client._remote:
|
||||||
targets = package.remote_browser_targets(client._remote, key=client._key)
|
targets = _with_profile_display(package.remote_browser_targets(client._remote, key=client._key))
|
||||||
else:
|
else:
|
||||||
targets = package.active_browser_targets()
|
targets = package.active_browser_targets()
|
||||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ class SessionNS(Namespace):
|
|||||||
def diff(self, name_a: str, name_b: str) -> dict:
|
def diff(self, name_a: str, name_b: str) -> dict:
|
||||||
"""Diff two saved sessions."""
|
"""Diff two saved sessions."""
|
||||||
|
|
||||||
|
@sdk_command("session.export", lambda self, name=None: {"name": name}, default={})
|
||||||
|
def export(self, name: str | None = None) -> dict:
|
||||||
|
"""Export one saved session, or all sessions when *name* is omitted."""
|
||||||
|
|
||||||
|
@sdk_command("session.import", lambda self, name, session, overwrite=False: {"name": name, "session": session, "overwrite": overwrite}, default={})
|
||||||
|
def import_(self, name: str, session: dict, *, overwrite: bool = False) -> dict:
|
||||||
|
"""Import a saved session payload under *name*."""
|
||||||
|
|
||||||
def list(self) -> list[dict]:
|
def list(self) -> list[dict]:
|
||||||
"""Return saved sessions.
|
"""Return saved sessions.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ from collections.abc import Callable, Iterable
|
|||||||
from browser_cli.models import BrowserCounts, Tab
|
from browser_cli.models import BrowserCounts, Tab
|
||||||
from browser_cli.sdk.base import Namespace
|
from browser_cli.sdk.base import Namespace
|
||||||
|
|
||||||
|
# Keep SDK-driven bulk closes comfortably below the native-host response
|
||||||
|
# timeout. The extension can close larger batches, but real browsers may take
|
||||||
|
# much longer when hundreds of visible tabs are involved.
|
||||||
|
BULK_CLOSE_CHUNK_SIZE = 50
|
||||||
|
|
||||||
class TabsNS(Namespace):
|
class TabsNS(Namespace):
|
||||||
"""List, open, close, move, and inspect browser tabs."""
|
"""List, open, close, move, and inspect browser tabs."""
|
||||||
|
|
||||||
@@ -24,17 +29,19 @@ class TabsNS(Namespace):
|
|||||||
wait: bool = False,
|
wait: bool = False,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
background: bool = False,
|
background: bool = False,
|
||||||
|
focus: bool = False,
|
||||||
window: str | None = None,
|
window: str | None = None,
|
||||||
group: str | None = None,
|
group: str | None = None,
|
||||||
) -> Tab:
|
) -> Tab:
|
||||||
"""Open *url* in a new tab and return a bound :class:`Tab`.
|
"""Open *url* in a new tab and return a bound :class:`Tab`.
|
||||||
|
|
||||||
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
|
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
|
||||||
|
Pass ``focus=True`` to explicitly bring the created tab/window forward.
|
||||||
"""
|
"""
|
||||||
if wait:
|
if wait:
|
||||||
return self._c.nav.open_wait(url, timeout=timeout, background=background, window=window, group=group)
|
return self._c.nav.open_wait(url, timeout=timeout, background=background, focus=focus, window=window, group=group)
|
||||||
return self.require_tab(
|
return self.require_tab(
|
||||||
self.command("navigate.open", {"url": url, "background": background, "window": window, "group": group}),
|
self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}),
|
||||||
"navigate.open returned unexpected data",
|
"navigate.open returned unexpected data",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,6 +80,20 @@ class TabsNS(Namespace):
|
|||||||
ids = None
|
ids = None
|
||||||
if tab_ids is not None:
|
if tab_ids is not None:
|
||||||
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
|
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
|
||||||
|
if ids is not None and len(ids) > BULK_CLOSE_CHUNK_SIZE and not inactive and not duplicates and tab_id is None:
|
||||||
|
closed = 0
|
||||||
|
for start in range(0, len(ids), BULK_CLOSE_CHUNK_SIZE):
|
||||||
|
chunk = ids[start:start + BULK_CLOSE_CHUNK_SIZE]
|
||||||
|
result = self.command("tabs.close", {
|
||||||
|
"tabId": None,
|
||||||
|
"tabIds": chunk,
|
||||||
|
"inactive": False,
|
||||||
|
"duplicates": False,
|
||||||
|
"gentleMode": gentle_mode,
|
||||||
|
})
|
||||||
|
closed += self.field(result, "closed", len(chunk))
|
||||||
|
return closed
|
||||||
|
|
||||||
result = self.command("tabs.close", {
|
result = self.command("tabs.close", {
|
||||||
"tabId": tab_id,
|
"tabId": tab_id,
|
||||||
"tabIds": ids,
|
"tabIds": ids,
|
||||||
|
|||||||
@@ -4,11 +4,32 @@ from __future__ import annotations
|
|||||||
import functools
|
import functools
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import TypeVar
|
from typing import Protocol, TypeVar, cast
|
||||||
|
|
||||||
F = TypeVar("F", bound=Callable)
|
F = TypeVar("F", bound=Callable)
|
||||||
_NO_INJECT = object()
|
_NO_INJECT = object()
|
||||||
|
|
||||||
|
class _WorkflowTabs(Protocol):
|
||||||
|
def active(self): ...
|
||||||
|
def open(self, *args, **kwargs): ...
|
||||||
|
def watch_url(self, *args, **kwargs): ...
|
||||||
|
|
||||||
|
class _WorkflowDom(Protocol):
|
||||||
|
def wait_for(self, *args, **kwargs): ...
|
||||||
|
|
||||||
|
class _WorkflowPerf(Protocol):
|
||||||
|
def status(self): ...
|
||||||
|
def set_profile(self, profile: str): ...
|
||||||
|
|
||||||
|
class _WorkflowSession(Protocol):
|
||||||
|
def save(self, name: str): ...
|
||||||
|
|
||||||
|
class _WorkflowClient(Protocol):
|
||||||
|
tabs: _WorkflowTabs
|
||||||
|
dom: _WorkflowDom
|
||||||
|
perf: _WorkflowPerf
|
||||||
|
session: _WorkflowSession
|
||||||
|
|
||||||
class WorkflowDecoratorsMixin:
|
class WorkflowDecoratorsMixin:
|
||||||
"""Shared implementation for sync and async workflow decorators.
|
"""Shared implementation for sync and async workflow decorators.
|
||||||
|
|
||||||
@@ -17,7 +38,7 @@ class WorkflowDecoratorsMixin:
|
|||||||
in lockstep.
|
in lockstep.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_c: object
|
_c: _WorkflowClient
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _inject(kwargs: dict, keyword: str | None, value):
|
def _inject(kwargs: dict, keyword: str | None, value):
|
||||||
@@ -62,7 +83,7 @@ class WorkflowDecoratorsMixin:
|
|||||||
finally:
|
finally:
|
||||||
if cleanup is not None:
|
if cleanup is not None:
|
||||||
self._run(cleanup, value)
|
self._run(cleanup, value)
|
||||||
return wrapper # type: ignore[return-value]
|
return cast(F, wrapper)
|
||||||
|
|
||||||
return decorator(func) if func is not None else decorator
|
return decorator(func) if func is not None else decorator
|
||||||
|
|
||||||
@@ -72,7 +93,7 @@ class WorkflowDecoratorsMixin:
|
|||||||
By default the tab is injected as ``tab=...``. Pass ``keyword=None`` to
|
By default the tab is injected as ``tab=...``. Pass ``keyword=None`` to
|
||||||
pass it as the first positional argument instead.
|
pass it as the first positional argument instead.
|
||||||
"""
|
"""
|
||||||
return self._value_decorator(func, self._c.tabs.active, keyword=keyword) # type: ignore[attr-defined]
|
return self._value_decorator(func, self._c.tabs.active, keyword=keyword)
|
||||||
|
|
||||||
def new_tab(
|
def new_tab(
|
||||||
self,
|
self,
|
||||||
@@ -81,6 +102,7 @@ class WorkflowDecoratorsMixin:
|
|||||||
wait: bool = False,
|
wait: bool = False,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
background: bool = False,
|
background: bool = False,
|
||||||
|
focus: bool = False,
|
||||||
window: str | None = None,
|
window: str | None = None,
|
||||||
group: str | None = None,
|
group: str | None = None,
|
||||||
close: bool = False,
|
close: bool = False,
|
||||||
@@ -92,11 +114,12 @@ class WorkflowDecoratorsMixin:
|
|||||||
wrapped function returns or raises.
|
wrapped function returns or raises.
|
||||||
"""
|
"""
|
||||||
def open_tab():
|
def open_tab():
|
||||||
return self._c.tabs.open( # type: ignore[attr-defined]
|
return self._c.tabs.open(
|
||||||
url,
|
url,
|
||||||
wait=wait,
|
wait=wait,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
background=background,
|
background=background,
|
||||||
|
focus=focus,
|
||||||
window=window,
|
window=window,
|
||||||
group=group,
|
group=group,
|
||||||
)
|
)
|
||||||
@@ -122,7 +145,7 @@ class WorkflowDecoratorsMixin:
|
|||||||
the wrapped function. By default the result is not injected.
|
the wrapped function. By default the result is not injected.
|
||||||
"""
|
"""
|
||||||
def wait():
|
def wait():
|
||||||
return self._c.dom.wait_for( # type: ignore[attr-defined]
|
return self._c.dom.wait_for(
|
||||||
selector,
|
selector,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
visible=visible,
|
visible=visible,
|
||||||
@@ -143,7 +166,7 @@ class WorkflowDecoratorsMixin:
|
|||||||
):
|
):
|
||||||
"""Wait until a tab URL matches *pattern* before calling the function."""
|
"""Wait until a tab URL matches *pattern* before calling the function."""
|
||||||
def wait():
|
def wait():
|
||||||
return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) # type: ignore[attr-defined]
|
return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
|
||||||
|
|
||||||
inject = keyword if keyword is not None else _NO_INJECT
|
inject = keyword if keyword is not None else _NO_INJECT
|
||||||
return self._value_decorator(None, wait, keyword=inject)
|
return self._value_decorator(None, wait, keyword=inject)
|
||||||
@@ -155,19 +178,19 @@ class WorkflowDecoratorsMixin:
|
|||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
previous = None
|
previous = None
|
||||||
if restore:
|
if restore:
|
||||||
previous = self._run(self._c.perf.status).get("performanceProfile") # type: ignore[attr-defined]
|
previous = self._run(self._c.perf.status).get("performanceProfile")
|
||||||
self._run(self._c.perf.set_profile, profile) # type: ignore[attr-defined]
|
self._run(self._c.perf.set_profile, profile)
|
||||||
try:
|
try:
|
||||||
return self._call_wrapped(fn, *args, **kwargs)
|
return self._call_wrapped(fn, *args, **kwargs)
|
||||||
finally:
|
finally:
|
||||||
if previous:
|
if previous:
|
||||||
self._run(self._c.perf.set_profile, previous) # type: ignore[attr-defined]
|
self._run(self._c.perf.set_profile, previous)
|
||||||
return wrapper # type: ignore[return-value]
|
return cast(F, wrapper)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
def save_session_before(self, name: str):
|
def save_session_before(self, name: str):
|
||||||
"""Save the current browser session before running the function."""
|
"""Save the current browser session before running the function."""
|
||||||
return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT) # type: ignore[attr-defined]
|
return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT)
|
||||||
|
|
||||||
def retry(
|
def retry(
|
||||||
self,
|
self,
|
||||||
@@ -192,7 +215,7 @@ class WorkflowDecoratorsMixin:
|
|||||||
raise
|
raise
|
||||||
if delay > 0:
|
if delay > 0:
|
||||||
self._sleep(delay)
|
self._sleep(delay)
|
||||||
raise last_error # type: ignore[misc]
|
raise cast(BaseException, last_error)
|
||||||
return wrapper # type: ignore[return-value]
|
return cast(F, wrapper)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Challenge-frame helpers for ``browser-cli serve``."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
|
||||||
|
|
||||||
|
async def load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
|
||||||
|
if auth_keys_path is None:
|
||||||
|
return None
|
||||||
|
from browser_cli.auth import load_authorized_keys
|
||||||
|
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
|
||||||
|
|
||||||
|
async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
|
||||||
|
nonce = secrets.token_hex(32)
|
||||||
|
pq_private_key = None
|
||||||
|
challenge_msg = {
|
||||||
|
"type": "challenge",
|
||||||
|
"nonce": nonce,
|
||||||
|
"server_version": get_installed_version(),
|
||||||
|
"min_client_version": PROTOCOL_MIN_CLIENT,
|
||||||
|
}
|
||||||
|
if auth_keys_path is not None:
|
||||||
|
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
|
||||||
|
pq_keypair = await asyncio.to_thread(pq_kex_server_keypair)
|
||||||
|
if pq_keypair is not None:
|
||||||
|
pq_private_key, pq_public_key = pq_keypair
|
||||||
|
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
|
||||||
|
return nonce, pq_private_key, challenge_msg
|
||||||
@@ -16,11 +16,19 @@ class ServeControlMixin:
|
|||||||
|
|
||||||
async def handle_control_command(self, msg: dict) -> bool:
|
async def handle_control_command(self, msg: dict) -> bool:
|
||||||
if self.command == "browser-cli.targets":
|
if self.command == "browser-cli.targets":
|
||||||
from browser_cli.client import active_browser_targets
|
from browser_cli.client import active_browser_targets, send_command
|
||||||
targets = [
|
targets = []
|
||||||
{"profile": target.profile, "displayName": target.display_name}
|
for target in active_browser_targets(include_remotes=False):
|
||||||
for target in active_browser_targets(include_remotes=False)
|
item = {"profile": target.profile, "displayName": target.display_name}
|
||||||
]
|
try:
|
||||||
|
clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True)
|
||||||
|
if clients:
|
||||||
|
browser_name = clients[0].get("name")
|
||||||
|
if browser_name:
|
||||||
|
item["browserName"] = browser_name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
targets.append(item)
|
||||||
await self.send_ok(targets, self.command)
|
await self.send_ok(targets, self.command)
|
||||||
log_request(self.addr, self.command, None, "OK")
|
log_request(self.addr, self.command, None, "OK")
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import secrets
|
|
||||||
import socket
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,10 +16,10 @@ from browser_cli import transport
|
|||||||
from browser_cli.compat import adapt_auth
|
from browser_cli.compat import adapt_auth
|
||||||
from browser_cli.framing import async_recv_frame, async_send_frame
|
from browser_cli.framing import async_recv_frame, async_send_frame
|
||||||
from browser_cli.serve.auth import ServeAuthMixin
|
from browser_cli.serve.auth import ServeAuthMixin
|
||||||
|
from browser_cli.serve.challenge import build_challenge as _build_challenge, load_auth_keys as _load_auth_keys
|
||||||
from browser_cli.serve.control import ServeControlMixin
|
from browser_cli.serve.control import ServeControlMixin
|
||||||
from browser_cli.serve.logging import console, log_request
|
from browser_cli.serve.logging import console, log_request
|
||||||
from browser_cli.serve.proxy import ServeProxyMixin
|
from browser_cli.serve.proxy import ServeProxyMixin
|
||||||
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
|
|
||||||
|
|
||||||
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
|
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
|
||||||
await async_send_frame(writer, data)
|
await async_send_frame(writer, data)
|
||||||
@@ -140,29 +139,6 @@ async def _async_handle_client(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def _load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
|
|
||||||
if auth_keys_path is None:
|
|
||||||
return None
|
|
||||||
from browser_cli.auth import load_authorized_keys
|
|
||||||
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
|
|
||||||
|
|
||||||
async def _build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
|
|
||||||
nonce = secrets.token_hex(32)
|
|
||||||
pq_private_key = None
|
|
||||||
challenge_msg = {
|
|
||||||
"type": "challenge",
|
|
||||||
"nonce": nonce,
|
|
||||||
"server_version": get_installed_version(),
|
|
||||||
"min_client_version": PROTOCOL_MIN_CLIENT,
|
|
||||||
}
|
|
||||||
if auth_keys_path is not None:
|
|
||||||
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
|
|
||||||
pq_keypair = await asyncio.to_thread(pq_kex_server_keypair)
|
|
||||||
if pq_keypair is not None:
|
|
||||||
pq_private_key, pq_public_key = pq_keypair
|
|
||||||
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
|
|
||||||
return nonce, pq_private_key, challenge_msg
|
|
||||||
|
|
||||||
def _handle_client(
|
def _handle_client(
|
||||||
client_sock: socket.socket,
|
client_sock: socket.socket,
|
||||||
addr: tuple,
|
addr: tuple,
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
"""Response payload encoding for the TCP serve <-> client leg.
|
|
||||||
|
|
||||||
The wire frame stays ``4-byte LE length + payload``. The payload is made
|
|
||||||
self-describing so old peers keep working unchanged:
|
|
||||||
|
|
||||||
* A payload that starts with ``{`` or ``[`` is plain JSON (the historical
|
|
||||||
format). Old clients and old servers only ever produce/consume this.
|
|
||||||
* Any other leading byte is a 1-byte codec tag followed by the encoded body.
|
|
||||||
The tag's high nibble selects serialization, the low nibble compression::
|
|
||||||
|
|
||||||
tag = (serialization << 4) | compression
|
|
||||||
|
|
||||||
This is only ever emitted toward a peer that advertised support for it, so it
|
|
||||||
is fully backward compatible: clients announce what they can decode via the
|
|
||||||
``accept_encoding`` field in their request, and the server encodes the
|
|
||||||
response accordingly. Requests themselves stay plain JSON (they are tiny).
|
|
||||||
|
|
||||||
Compression is the big win — response payloads (``extract.html``,
|
|
||||||
``dom.query``, ``tabs.list`` over hundreds of tabs, base64 screenshots) are
|
|
||||||
heavy and text-like. msgpack additionally lets ``tabs.screenshot`` ship the
|
|
||||||
image as raw bytes instead of a base64 data URL (~33% smaller before
|
|
||||||
compression); the client transparently rebuilds the data URL so the SDK/CLI
|
|
||||||
API is unchanged.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import gzip
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import zlib
|
|
||||||
|
|
||||||
from browser_cli.constants import (
|
|
||||||
COMP_GZIP,
|
|
||||||
COMP_NONE,
|
|
||||||
COMP_ZLIB,
|
|
||||||
COMP_ZSTD,
|
|
||||||
DEFAULT_TRANSPORT_THRESHOLD,
|
|
||||||
SER_JSON,
|
|
||||||
SER_MSGPACK,
|
|
||||||
)
|
|
||||||
|
|
||||||
try: # optional: better ratio + speed than zlib/gzip
|
|
||||||
import zstandard as _zstd
|
|
||||||
except Exception: # pragma: no cover - depends on optional extra
|
|
||||||
_zstd = None
|
|
||||||
|
|
||||||
try: # optional: alternate serialization + raw binary for screenshots
|
|
||||||
import msgpack as _msgpack
|
|
||||||
except Exception: # pragma: no cover - depends on optional extra
|
|
||||||
_msgpack = None
|
|
||||||
|
|
||||||
# ── codec ids ────────────────────────────────────────────────────────────────
|
|
||||||
_SER_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
|
|
||||||
_SER_ID = {v: k for k, v in _SER_NAME.items()}
|
|
||||||
_COMP_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
|
|
||||||
_COMP_ID = {v: k for k, v in _COMP_NAME.items()}
|
|
||||||
|
|
||||||
# Don't compress payloads smaller than this — the header/CPU cost is not worth it.
|
|
||||||
|
|
||||||
# JSON top-level values always start with one of these bytes; a tag byte never does.
|
|
||||||
_JSON_FIRST_BYTES = frozenset(b"{[")
|
|
||||||
|
|
||||||
def msgpack_available() -> bool:
|
|
||||||
return _msgpack is not None
|
|
||||||
|
|
||||||
def zstd_available() -> bool:
|
|
||||||
return _zstd is not None
|
|
||||||
|
|
||||||
def supported_serialization() -> list[str]:
|
|
||||||
"""Serializations this build can produce/consume, best first."""
|
|
||||||
return (["msgpack"] if _msgpack is not None else []) + ["json"]
|
|
||||||
|
|
||||||
def supported_compression() -> list[str]:
|
|
||||||
"""Compression codecs this build can produce/consume, best first."""
|
|
||||||
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
|
|
||||||
|
|
||||||
def client_accept_encoding() -> dict:
|
|
||||||
"""What the local client advertises it can decode (sent with each request)."""
|
|
||||||
return {"ser": supported_serialization(), "comp": supported_compression()}
|
|
||||||
|
|
||||||
# ── compression primitives ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _compress(comp_id: int, data: bytes) -> bytes:
|
|
||||||
if comp_id == COMP_NONE:
|
|
||||||
return data
|
|
||||||
if comp_id == COMP_ZLIB:
|
|
||||||
return zlib.compress(data, 6)
|
|
||||||
if comp_id == COMP_GZIP:
|
|
||||||
return gzip.compress(data, compresslevel=6)
|
|
||||||
if comp_id == COMP_ZSTD:
|
|
||||||
if _zstd is None:
|
|
||||||
raise ValueError("zstd compression requested but zstandard is not installed")
|
|
||||||
return _zstd.ZstdCompressor(level=10).compress(data)
|
|
||||||
raise ValueError(f"unknown compression id {comp_id}")
|
|
||||||
|
|
||||||
def _decompress(comp_id: int, data: bytes) -> bytes:
|
|
||||||
if comp_id == COMP_NONE:
|
|
||||||
return data
|
|
||||||
if comp_id == COMP_ZLIB:
|
|
||||||
return zlib.decompress(data)
|
|
||||||
if comp_id == COMP_GZIP:
|
|
||||||
return gzip.decompress(data)
|
|
||||||
if comp_id == COMP_ZSTD:
|
|
||||||
if _zstd is None:
|
|
||||||
raise ValueError("zstd payload received but zstandard is not installed")
|
|
||||||
return _zstd.ZstdDecompressor().decompress(data)
|
|
||||||
raise ValueError(f"unknown compression id {comp_id}")
|
|
||||||
|
|
||||||
# ── codec negotiation ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _choose(accept: dict | None) -> tuple[int, int]:
|
|
||||||
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
|
|
||||||
accept = accept if isinstance(accept, dict) else {}
|
|
||||||
accept_ser = accept.get("ser") or ["json"]
|
|
||||||
accept_comp = accept.get("comp") or []
|
|
||||||
|
|
||||||
ser = SER_JSON
|
|
||||||
if _msgpack is not None and "msgpack" in accept_ser:
|
|
||||||
ser = SER_MSGPACK
|
|
||||||
|
|
||||||
comp = COMP_NONE
|
|
||||||
for name in supported_compression(): # server preference: zstd > gzip > zlib
|
|
||||||
if name in accept_comp:
|
|
||||||
comp = _COMP_ID[name]
|
|
||||||
break
|
|
||||||
return ser, comp
|
|
||||||
|
|
||||||
# ── raw-binary hoisting (screenshots) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
|
|
||||||
_B64_MARKER = "__b64__"
|
|
||||||
|
|
||||||
def _hoist_screenshot(obj, command: str | None):
|
|
||||||
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
|
|
||||||
|
|
||||||
Gated to ``tabs.screenshot`` so we never touch arbitrary page-derived data.
|
|
||||||
"""
|
|
||||||
if command != "tabs.screenshot" or not isinstance(obj, dict):
|
|
||||||
return obj
|
|
||||||
data = obj.get("data")
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return obj
|
|
||||||
url = data.get("dataUrl")
|
|
||||||
if not isinstance(url, str):
|
|
||||||
return obj
|
|
||||||
m = _DATA_URL_RE.match(url)
|
|
||||||
if not m:
|
|
||||||
return obj
|
|
||||||
try:
|
|
||||||
raw = base64.b64decode(m.group(2))
|
|
||||||
except Exception:
|
|
||||||
return obj
|
|
||||||
new_data = dict(data)
|
|
||||||
new_data["dataUrl"] = {_B64_MARKER: True, "mime": m.group(1), "raw": raw}
|
|
||||||
return {**obj, "data": new_data}
|
|
||||||
|
|
||||||
def _unhoist_binary(obj):
|
|
||||||
"""Rebuild any hoisted data URL so callers see the original string again."""
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
raw = obj.get("raw")
|
|
||||||
if obj.get(_B64_MARKER) and isinstance(raw, (bytes, bytearray)):
|
|
||||||
mime = obj.get("mime") or "application/octet-stream"
|
|
||||||
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
|
|
||||||
return {k: _unhoist_binary(v) for k, v in obj.items()}
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return [_unhoist_binary(v) for v in obj]
|
|
||||||
return obj
|
|
||||||
|
|
||||||
# ── encode / decode ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def encode_response(obj, accept: dict | None = None, command: str | None = None,
|
|
||||||
threshold: int = DEFAULT_TRANSPORT_THRESHOLD) -> bytes:
|
|
||||||
"""Encode a response object for the chosen/accepted codec.
|
|
||||||
|
|
||||||
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
|
|
||||||
what an old server would have sent.
|
|
||||||
"""
|
|
||||||
ser, comp = _choose(accept)
|
|
||||||
|
|
||||||
if ser == SER_MSGPACK:
|
|
||||||
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
|
|
||||||
else:
|
|
||||||
body = json.dumps(obj).encode("utf-8")
|
|
||||||
|
|
||||||
if comp != COMP_NONE and len(body) >= threshold:
|
|
||||||
body = _compress(comp, body)
|
|
||||||
else:
|
|
||||||
comp = COMP_NONE
|
|
||||||
|
|
||||||
if ser == SER_JSON and comp == COMP_NONE:
|
|
||||||
return body # plain JSON — historical wire format, no tag byte
|
|
||||||
|
|
||||||
return bytes([(ser << 4) | comp]) + body
|
|
||||||
|
|
||||||
def decode_response(raw: bytes | None):
|
|
||||||
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
if not raw:
|
|
||||||
raise ValueError("empty response payload")
|
|
||||||
if raw[0] in _JSON_FIRST_BYTES:
|
|
||||||
return json.loads(raw)
|
|
||||||
|
|
||||||
tag = raw[0]
|
|
||||||
ser, comp = tag >> 4, tag & 0x0F
|
|
||||||
body = _decompress(comp, raw[1:])
|
|
||||||
if ser == SER_MSGPACK:
|
|
||||||
if _msgpack is None:
|
|
||||||
raise ValueError("msgpack payload received but msgpack is not installed")
|
|
||||||
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
|
|
||||||
if ser == SER_JSON:
|
|
||||||
return json.loads(body)
|
|
||||||
raise ValueError(f"unknown serialization id {ser}")
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Response payload encoding for the TCP serve <-> client leg.
|
||||||
|
|
||||||
|
The wire frame stays ``4-byte LE length + payload``. Payloads are plain JSON
|
||||||
|
for legacy peers, or a 1-byte codec tag followed by serialized/compressed data
|
||||||
|
when the peer advertised support for it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from browser_cli.constants import COMP_GZIP, COMP_NONE, COMP_ZLIB, COMP_ZSTD, DEFAULT_TRANSPORT_THRESHOLD, SER_JSON, SER_MSGPACK
|
||||||
|
from browser_cli.transport.binary import hoist_screenshot as _hoist_screenshot, unhoist_binary as _unhoist_binary
|
||||||
|
from browser_cli.transport.codecs import (
|
||||||
|
JSON_FIRST_BYTES as _JSON_FIRST_BYTES,
|
||||||
|
_msgpack,
|
||||||
|
choose_codec as _choose,
|
||||||
|
client_accept_encoding,
|
||||||
|
compress_payload as _compress,
|
||||||
|
decompress_payload as _decompress,
|
||||||
|
msgpack_available,
|
||||||
|
supported_compression,
|
||||||
|
supported_serialization,
|
||||||
|
zstd_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
def encode_response(
|
||||||
|
obj,
|
||||||
|
accept: dict | None = None,
|
||||||
|
command: str | None = None,
|
||||||
|
threshold: int = DEFAULT_TRANSPORT_THRESHOLD,
|
||||||
|
) -> bytes:
|
||||||
|
"""Encode a response object for the chosen/accepted codec.
|
||||||
|
|
||||||
|
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
|
||||||
|
what an old server would have sent.
|
||||||
|
"""
|
||||||
|
ser, comp = _choose(accept)
|
||||||
|
|
||||||
|
if ser == SER_MSGPACK:
|
||||||
|
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
|
||||||
|
else:
|
||||||
|
body = json.dumps(obj).encode("utf-8")
|
||||||
|
|
||||||
|
if comp != COMP_NONE and len(body) >= threshold:
|
||||||
|
body = _compress(comp, body)
|
||||||
|
else:
|
||||||
|
comp = COMP_NONE
|
||||||
|
|
||||||
|
if ser == SER_JSON and comp == COMP_NONE:
|
||||||
|
return body # plain JSON — historical wire format, no tag byte
|
||||||
|
|
||||||
|
return bytes([(ser << 4) | comp]) + body
|
||||||
|
|
||||||
|
def decode_response(raw: bytes | None):
|
||||||
|
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("empty response payload")
|
||||||
|
if raw[0] in _JSON_FIRST_BYTES:
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
tag = raw[0]
|
||||||
|
ser, comp = tag >> 4, tag & 0x0F
|
||||||
|
body = _decompress(comp, raw[1:])
|
||||||
|
if ser == SER_MSGPACK:
|
||||||
|
if _msgpack is None:
|
||||||
|
raise ValueError("msgpack payload received but msgpack is not installed")
|
||||||
|
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
|
||||||
|
if ser == SER_JSON:
|
||||||
|
return json.loads(body)
|
||||||
|
raise ValueError(f"unknown serialization id {ser}")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Raw-binary hoisting helpers for encoded response payloads."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
|
||||||
|
DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
|
||||||
|
B64_MARKER = "__b64__"
|
||||||
|
|
||||||
|
def hoist_screenshot(obj, command: str | None):
|
||||||
|
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
|
||||||
|
|
||||||
|
Gated to ``tabs.screenshot`` so arbitrary page-derived data is never touched.
|
||||||
|
"""
|
||||||
|
if command != "tabs.screenshot" or not isinstance(obj, dict):
|
||||||
|
return obj
|
||||||
|
data = obj.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return obj
|
||||||
|
url = data.get("dataUrl")
|
||||||
|
if not isinstance(url, str):
|
||||||
|
return obj
|
||||||
|
match = DATA_URL_RE.match(url)
|
||||||
|
if not match:
|
||||||
|
return obj
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(match.group(2))
|
||||||
|
except Exception:
|
||||||
|
return obj
|
||||||
|
new_data = dict(data)
|
||||||
|
new_data["dataUrl"] = {B64_MARKER: True, "mime": match.group(1), "raw": raw}
|
||||||
|
return {**obj, "data": new_data}
|
||||||
|
|
||||||
|
def unhoist_binary(obj):
|
||||||
|
"""Rebuild any hoisted data URL so callers see the original string again."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
raw = obj.get("raw")
|
||||||
|
if obj.get(B64_MARKER) and isinstance(raw, (bytes, bytearray)):
|
||||||
|
mime = obj.get("mime") or "application/octet-stream"
|
||||||
|
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
|
||||||
|
return {key: unhoist_binary(value) for key, value in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [unhoist_binary(value) for value in obj]
|
||||||
|
return obj
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Serialization/compression primitives for TCP response payloads."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
from browser_cli.constants import COMP_GZIP, COMP_NONE, COMP_ZLIB, COMP_ZSTD, SER_JSON, SER_MSGPACK
|
||||||
|
|
||||||
|
try: # optional: better ratio + speed than zlib/gzip
|
||||||
|
import zstandard as _zstd
|
||||||
|
except Exception: # pragma: no cover - depends on optional extra
|
||||||
|
_zstd = None
|
||||||
|
|
||||||
|
try: # optional: alternate serialization + raw binary for screenshots
|
||||||
|
import msgpack as _msgpack
|
||||||
|
except Exception: # pragma: no cover - depends on optional extra
|
||||||
|
_msgpack = None
|
||||||
|
|
||||||
|
SERIALIZATION_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
|
||||||
|
SERIALIZATION_ID = {value: key for key, value in SERIALIZATION_NAME.items()}
|
||||||
|
COMPRESSION_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
|
||||||
|
COMPRESSION_ID = {value: key for key, value in COMPRESSION_NAME.items()}
|
||||||
|
JSON_FIRST_BYTES = frozenset(b"{[")
|
||||||
|
|
||||||
|
def msgpack_available() -> bool:
|
||||||
|
return _msgpack is not None
|
||||||
|
|
||||||
|
def zstd_available() -> bool:
|
||||||
|
return _zstd is not None
|
||||||
|
|
||||||
|
def supported_serialization() -> list[str]:
|
||||||
|
"""Serializations this build can produce/consume, best first."""
|
||||||
|
return (["msgpack"] if _msgpack is not None else []) + ["json"]
|
||||||
|
|
||||||
|
def supported_compression() -> list[str]:
|
||||||
|
"""Compression codecs this build can produce/consume, best first."""
|
||||||
|
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
|
||||||
|
|
||||||
|
def client_accept_encoding() -> dict:
|
||||||
|
"""What the local client advertises it can decode (sent with each request)."""
|
||||||
|
return {"ser": supported_serialization(), "comp": supported_compression()}
|
||||||
|
|
||||||
|
def compress_payload(comp_id: int, data: bytes) -> bytes:
|
||||||
|
if comp_id == COMP_NONE:
|
||||||
|
return data
|
||||||
|
if comp_id == COMP_ZLIB:
|
||||||
|
return zlib.compress(data, 6)
|
||||||
|
if comp_id == COMP_GZIP:
|
||||||
|
return gzip.compress(data, compresslevel=6)
|
||||||
|
if comp_id == COMP_ZSTD:
|
||||||
|
if _zstd is None:
|
||||||
|
raise ValueError("zstd compression requested but zstandard is not installed")
|
||||||
|
return _zstd.ZstdCompressor(level=10).compress(data)
|
||||||
|
raise ValueError(f"unknown compression id {comp_id}")
|
||||||
|
|
||||||
|
def decompress_payload(comp_id: int, data: bytes) -> bytes:
|
||||||
|
if comp_id == COMP_NONE:
|
||||||
|
return data
|
||||||
|
if comp_id == COMP_ZLIB:
|
||||||
|
return zlib.decompress(data)
|
||||||
|
if comp_id == COMP_GZIP:
|
||||||
|
return gzip.decompress(data)
|
||||||
|
if comp_id == COMP_ZSTD:
|
||||||
|
if _zstd is None:
|
||||||
|
raise ValueError("zstd payload received but zstandard is not installed")
|
||||||
|
return _zstd.ZstdDecompressor().decompress(data)
|
||||||
|
raise ValueError(f"unknown compression id {comp_id}")
|
||||||
|
|
||||||
|
def choose_codec(accept: dict | None) -> tuple[int, int]:
|
||||||
|
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
|
||||||
|
accept = accept if isinstance(accept, dict) else {}
|
||||||
|
accept_ser = accept.get("ser") or ["json"]
|
||||||
|
accept_comp = accept.get("comp") or []
|
||||||
|
|
||||||
|
serialization = SER_JSON
|
||||||
|
if _msgpack is not None and "msgpack" in accept_ser:
|
||||||
|
serialization = SER_MSGPACK
|
||||||
|
|
||||||
|
compression = COMP_NONE
|
||||||
|
for name in supported_compression(): # server preference: zstd > gzip > zlib
|
||||||
|
if name in accept_comp:
|
||||||
|
compression = COMPRESSION_ID[name]
|
||||||
|
break
|
||||||
|
return serialization, compression
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from importlib.metadata import version as _pkg_version
|
from importlib.metadata import version as _pkg_version
|
||||||
|
|
||||||
from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT
|
from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT, PYPI_PACKAGE_NAME
|
||||||
|
|
||||||
def parse_version(v: str) -> tuple[int, ...]:
|
def parse_version(v: str) -> tuple[int, ...]:
|
||||||
try:
|
try:
|
||||||
@@ -10,7 +10,7 @@ def parse_version(v: str) -> tuple[int, ...]:
|
|||||||
|
|
||||||
def get_installed_version() -> str:
|
def get_installed_version() -> str:
|
||||||
try:
|
try:
|
||||||
return _pkg_version("browser-cli")
|
return _pkg_version(PYPI_PACKAGE_NAME)
|
||||||
except Exception:
|
except Exception:
|
||||||
return "0.0.0"
|
return "0.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ pause
|
|||||||
header "3/8 · Create 'research' group and open URLs into it"
|
header "3/8 · Create 'research' group and open URLs into it"
|
||||||
$CLI groups create research
|
$CLI groups create research
|
||||||
echo ""
|
echo ""
|
||||||
$CLI nav open https://example.com --group research --bg
|
$CLI nav open https://example.com --group research
|
||||||
$CLI nav open https://wikipedia.org --group research --bg
|
$CLI nav open https://wikipedia.org --group research
|
||||||
echo ""
|
echo ""
|
||||||
echo " Tabs are now open inside the 'research' group in your browser."
|
echo " Tabs are now open inside the 'research' group in your browser."
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
|
||||||
<title>browser-cli icon</title>
|
<title>browser-cli icon</title>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg" x1="12" y1="10" x2="116" y2="118" gradientUnits="userSpaceOnUse">
|
<linearGradient id="bg" x1="16" y1="16" x2="112" y2="112" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#0f766e" />
|
<stop offset="0" stop-color="#0f766e" />
|
||||||
<stop offset="1" stop-color="#0f172a" />
|
<stop offset="1" stop-color="#0f172a" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="panel" x1="28" y1="24" x2="100" y2="104" gradientUnits="userSpaceOnUse">
|
<linearGradient id="panel" x1="32" y1="29" x2="96" y2="99" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#f8fafc" />
|
<stop offset="0" stop-color="#f8fafc" />
|
||||||
<stop offset="1" stop-color="#cbd5e1" />
|
<stop offset="1" stop-color="#cbd5e1" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#bg)" />
|
<!-- Chrome Web Store compliant: 96x96 artwork centered in 128x128 canvas. -->
|
||||||
<rect x="25" y="25" width="78" height="66" rx="14" fill="url(#panel)" />
|
<rect x="16" y="16" width="96" height="96" rx="24" fill="url(#bg)" />
|
||||||
<rect x="25" y="25" width="78" height="15" rx="14" fill="#94a3b8" />
|
<rect x="17" y="17" width="94" height="94" rx="23" fill="none" stroke="#ccfbf1" stroke-opacity="0.55" stroke-width="2" />
|
||||||
<circle cx="36" cy="32.5" r="2.5" fill="#f8fafc" />
|
|
||||||
<circle cx="44" cy="32.5" r="2.5" fill="#f8fafc" opacity="0.85" />
|
|
||||||
<circle cx="52" cy="32.5" r="2.5" fill="#f8fafc" opacity="0.7" />
|
|
||||||
|
|
||||||
<path d="M46 56 35 64l11 8" fill="none" stroke="#0f172a" stroke-linecap="round" stroke-linejoin="round" stroke-width="8" />
|
<rect x="32" y="31" width="64" height="54" rx="11" fill="url(#panel)" />
|
||||||
<path d="M62 52h19" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="8" />
|
<path d="M32 42c0-6.075 4.925-11 11-11h42c6.075 0 11 4.925 11 11v3H32z" fill="#94a3b8" />
|
||||||
<path d="M62 65h26" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="8" />
|
<circle cx="42" cy="38.5" r="2.2" fill="#f8fafc" />
|
||||||
|
<circle cx="49" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.85" />
|
||||||
|
<circle cx="56" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.7" />
|
||||||
|
|
||||||
<rect x="69" y="77" width="26" height="17" rx="6" fill="#14b8a6" />
|
<path d="M49 57 40 64l9 7" fill="none" stroke="#0f172a" stroke-linecap="round" stroke-linejoin="round" stroke-width="7" />
|
||||||
<rect x="56" y="84" width="26" height="17" rx="6" fill="#2dd4bf" />
|
<path d="M62 55h17" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
|
||||||
<rect x="43" y="91" width="26" height="17" rx="6" fill="#99f6e4" />
|
<path d="M62 67h23" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
|
||||||
|
|
||||||
|
<rect x="70" y="78" width="22" height="15" rx="5" fill="#14b8a6" />
|
||||||
|
<rect x="59" y="84" width="22" height="15" rx="5" fill="#2dd4bf" />
|
||||||
|
<rect x="48" y="90" width="22" height="15" rx="5" fill="#99f6e4" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.12.1",
|
"version": "0.15.5",
|
||||||
"description": "Control your browser from the terminal or Python SDK",
|
"description": "Control your browser from the terminal or Python SDK",
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "browser-cli@yiprawr.dev",
|
||||||
|
"strict_min_version": "120.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"tabs",
|
||||||
"tabGroups",
|
"tabGroups",
|
||||||
@@ -10,8 +16,7 @@
|
|||||||
"windows",
|
"windows",
|
||||||
"storage",
|
"storage",
|
||||||
"alarms",
|
"alarms",
|
||||||
"nativeMessaging",
|
"nativeMessaging"
|
||||||
"cookies"
|
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Cross-browser WebExtension API entry point.
|
||||||
|
*
|
||||||
|
* Firefox exposes the Promise-based WebExtension API as `browser.*`.
|
||||||
|
* Chromium exposes the same extension API as `chrome.*`.
|
||||||
|
* Runtime modules import this neutral adapter as `api`, so Firefox uses its
|
||||||
|
* native `browser` object and Chromium uses its native `chrome` object. No
|
||||||
|
* browser-specific global is faked or overwritten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WebExtensionApi } from './types';
|
||||||
|
|
||||||
|
type WebExtensionGlobal = {
|
||||||
|
browser?: typeof browser;
|
||||||
|
chrome?: typeof chrome;
|
||||||
|
};
|
||||||
|
|
||||||
|
function currentApi(): typeof browser | typeof chrome {
|
||||||
|
const webExtensionGlobal = globalThis as object as WebExtensionGlobal;
|
||||||
|
const api = webExtensionGlobal.browser || webExtensionGlobal.chrome;
|
||||||
|
if (!api) {
|
||||||
|
throw new Error("WebExtension API is not available: expected browser.* or chrome.*");
|
||||||
|
}
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webExtApi = new Proxy({}, {
|
||||||
|
get(_target: object, property: string | symbol) {
|
||||||
|
return currentApi()[property as keyof ReturnType<typeof currentApi>];
|
||||||
|
},
|
||||||
|
}) as object as WebExtensionApi;
|
||||||
@@ -11,7 +11,7 @@ export interface CommandContext { jobs: JobManager; }
|
|||||||
/**
|
/**
|
||||||
* A command group bundles a set of related subcommands. `commands` is keyed by
|
* A command group bundles a set of related subcommands. `commands` is keyed by
|
||||||
* the FULL command id (e.g. "tabs.close") so groups spanning multiple
|
* the FULL command id (e.g. "tabs.close") so groups spanning multiple
|
||||||
* namespaces (dom/extract/page, storage/cookies, session/clients) register
|
* namespaces (dom/extract/page, storage, session/clients) register
|
||||||
* uniformly. `namespace` is documentation/grouping only.
|
* uniformly. `namespace` is documentation/grouping only.
|
||||||
*/
|
*/
|
||||||
export abstract class CommandGroup {
|
export abstract class CommandGroup {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { webExtApi as api } from '../browser-api';
|
||||||
import { CommandGroup } from './CommandGroup';
|
import { CommandGroup } from './CommandGroup';
|
||||||
import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup';
|
import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup';
|
||||||
import { NavigationCommands } from '../commands/navigation';
|
import { NavigationCommands } from '../commands/navigation';
|
||||||
@@ -74,7 +75,7 @@ export class CommandRegistry {
|
|||||||
/**
|
/**
|
||||||
* Builds the registry and registers every command group. The SessionCommands
|
* Builds the registry and registers every command group. The SessionCommands
|
||||||
* instance is returned alongside because index.ts wires its lifecycle methods
|
* instance is returned alongside because index.ts wires its lifecycle methods
|
||||||
* (chrome.tabs.onActivated → activateLazyTab) and NativeConnection references it
|
* (api.tabs.onActivated → activateLazyTab) and NativeConnection references it
|
||||||
* for the clients.rename_profile reconnect side-effect.
|
* for the clients.rename_profile reconnect side-effect.
|
||||||
*/
|
*/
|
||||||
export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } {
|
export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { webExtApi as api } from '../browser-api';
|
||||||
/**
|
/**
|
||||||
* Background-job retention helpers + the JobManager that owns the live job map.
|
* Background-job retention helpers + the JobManager that owns the live job map.
|
||||||
*
|
*
|
||||||
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of chrome.* /
|
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of api.* /
|
||||||
* service-worker side effects so the retention logic (memory-leak guard) can be
|
* service-worker side effects so the retention logic (memory-leak guard) can be
|
||||||
* unit-tested in isolation.
|
* unit-tested in isolation.
|
||||||
*/
|
*/
|
||||||
@@ -16,7 +17,7 @@ export const MAX_FINISHED_JOBS = 20;
|
|||||||
|
|
||||||
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
|
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
|
||||||
// dead tab), finalize the job as an error so its persist interval stops instead
|
// dead tab), finalize the job as an error so its persist interval stops instead
|
||||||
// of writing to chrome.storage.local every second forever.
|
// of writing to api.storage.local every second forever.
|
||||||
export const JOB_TIMEOUT_MS = 5 * 60 * 1000;
|
export const JOB_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,11 +66,11 @@ export class JobManager {
|
|||||||
const running = all.filter(job => job.status === "running");
|
const running = all.filter(job => job.status === "running");
|
||||||
const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS);
|
const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS);
|
||||||
const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest);
|
const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest);
|
||||||
await chrome.storage.local.set({ recentJobs });
|
await api.storage.local.set({ recentJobs });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evict the oldest finished jobs once their count exceeds the retention cap.
|
// Evict the oldest finished jobs once their count exceeds the retention cap.
|
||||||
// Recent finished jobs remain queryable via chrome.storage.local (persistJobs)
|
// Recent finished jobs remain queryable via api.storage.local (persistJobs)
|
||||||
// even after eviction from the in-memory Map.
|
// even after eviction from the in-memory Map.
|
||||||
private pruneJobs() {
|
private pruneJobs() {
|
||||||
pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS);
|
pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS);
|
||||||
@@ -143,7 +144,7 @@ export class JobManager {
|
|||||||
async status({ jobId }: { jobId?: string }) {
|
async status({ jobId }: { jobId?: string }) {
|
||||||
const job = this.jobs.get(jobId);
|
const job = this.jobs.get(jobId);
|
||||||
if (job) return { ...job };
|
if (job) return { ...job };
|
||||||
const { recentJobs } = await chrome.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
|
const { recentJobs } = await api.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
|
||||||
const stored = (recentJobs || []).find(entry => entry.id === jobId);
|
const stored = (recentJobs || []).find(entry => entry.id === jobId);
|
||||||
if (!stored) throw new Error(`Job '${jobId}' not found`);
|
if (!stored) throw new Error(`Job '${jobId}' not found`);
|
||||||
return stored;
|
return stored;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { webExtApi as api } from '../browser-api';
|
||||||
/**
|
/**
|
||||||
* Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound
|
* Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound
|
||||||
* message router that hands commands to the CommandRegistry.
|
* message router that hands commands to the CommandRegistry.
|
||||||
@@ -6,7 +7,7 @@
|
|||||||
import { getErrorMessage, getProfileAlias } from '../core';
|
import { getErrorMessage, getProfileAlias } from '../core';
|
||||||
import type { CommandRegistry } from './CommandRegistry';
|
import type { CommandRegistry } from './CommandRegistry';
|
||||||
import type { SessionCommands } from '../commands/session';
|
import type { SessionCommands } from '../commands/session';
|
||||||
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable } from '../types';
|
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable, RuntimePort } from '../types';
|
||||||
|
|
||||||
const NATIVE_HOST = "com.browsercli.host";
|
const NATIVE_HOST = "com.browsercli.host";
|
||||||
const DEBUG_LOG = false;
|
const DEBUG_LOG = false;
|
||||||
@@ -16,7 +17,7 @@ function debugLog(...args: Serializable[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class NativeConnection {
|
export class NativeConnection {
|
||||||
private port: chrome.runtime.Port | null = null;
|
private port: RuntimePort | null = null;
|
||||||
private keepaliveEnabled = true;
|
private keepaliveEnabled = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -26,17 +27,17 @@ export class NativeConnection {
|
|||||||
|
|
||||||
/** Registers all runtime listeners and opens the initial connection. */
|
/** Registers all runtime listeners and opens the initial connection. */
|
||||||
start() {
|
start() {
|
||||||
chrome.runtime.onInstalled.addListener(() => this.connect());
|
api.runtime.onInstalled.addListener(() => this.connect());
|
||||||
chrome.runtime.onStartup.addListener(() => this.connect());
|
api.runtime.onStartup.addListener(() => this.connect());
|
||||||
chrome.runtime.onSuspend.addListener(() => {
|
api.runtime.onSuspend.addListener(() => {
|
||||||
this.disconnectPort({ sendBye: true });
|
this.disconnectPort({ sendBye: true });
|
||||||
});
|
});
|
||||||
chrome.windows.onCreated.addListener(() => {
|
api.windows.onCreated.addListener(() => {
|
||||||
this.keepaliveEnabled = true;
|
this.keepaliveEnabled = true;
|
||||||
if (!this.port) this.connect();
|
if (!this.port) this.connect();
|
||||||
});
|
});
|
||||||
chrome.windows.onRemoved.addListener(async () => {
|
api.windows.onRemoved.addListener(async () => {
|
||||||
const windows = await chrome.windows.getAll({});
|
const windows = await api.windows.getAll({});
|
||||||
if (windows.length > 0) return;
|
if (windows.length > 0) return;
|
||||||
|
|
||||||
this.keepaliveEnabled = false;
|
this.keepaliveEnabled = false;
|
||||||
@@ -46,15 +47,15 @@ export class NativeConnection {
|
|||||||
// Reconnect poll — wakes the worker to re-establish the native port if it
|
// Reconnect poll — wakes the worker to re-establish the native port if it
|
||||||
// dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4)
|
// dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4)
|
||||||
// are silently clamped and log a warning, so we set it explicitly.
|
// are silently clamped and log a warning, so we set it explicitly.
|
||||||
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 });
|
api.alarms.create("keepalive", { periodInMinutes: 0.5 });
|
||||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
api.alarms.onAlarm.addListener((alarm) => {
|
||||||
if (alarm.name === "keepalive") {
|
if (alarm.name === "keepalive") {
|
||||||
if (!this.port && this.keepaliveEnabled) this.connect();
|
if (!this.port && this.keepaliveEnabled) this.connect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendControlMessage(targetPort: chrome.runtime.Port | null, message: ControlMessage) {
|
private sendControlMessage(targetPort: RuntimePort | null, message: ControlMessage) {
|
||||||
if (!targetPort) return;
|
if (!targetPort) return;
|
||||||
try {
|
try {
|
||||||
targetPort.postMessage(message);
|
targetPort.postMessage(message);
|
||||||
@@ -63,7 +64,7 @@ export class NativeConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendResponse(targetPort: chrome.runtime.Port | null, message: ResponseMessage) {
|
private sendResponse(targetPort: RuntimePort | null, message: ResponseMessage) {
|
||||||
if (!targetPort) return;
|
if (!targetPort) return;
|
||||||
try {
|
try {
|
||||||
targetPort.postMessage(message);
|
targetPort.postMessage(message);
|
||||||
@@ -90,12 +91,12 @@ export class NativeConnection {
|
|||||||
private async connect() {
|
private async connect() {
|
||||||
if (this.port || !this.keepaliveEnabled) return;
|
if (this.port || !this.keepaliveEnabled) return;
|
||||||
try {
|
try {
|
||||||
const nativePort = chrome.runtime.connectNative(NATIVE_HOST);
|
const nativePort = api.runtime.connectNative(NATIVE_HOST);
|
||||||
this.port = nativePort;
|
this.port = nativePort;
|
||||||
nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg));
|
nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg));
|
||||||
nativePort.onDisconnect.addListener(() => {
|
nativePort.onDisconnect.addListener(() => {
|
||||||
if (this.port === nativePort) this.port = null;
|
if (this.port === nativePort) this.port = null;
|
||||||
const err = chrome.runtime.lastError;
|
const err = api.runtime.lastError;
|
||||||
if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
|
if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
|
||||||
});
|
});
|
||||||
// Send hello so native host knows which profile/alias this is
|
// Send hello so native host knows which profile/alias this is
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { getSessions, runLargeOperation } from '../core';
|
import type { TabUpdateInfo } from '../types';
|
||||||
|
import { webExtApi as api } from '../browser-api';
|
||||||
|
import { getSessions, runLargeOperation, tabGroupsOnUpdated } from '../core';
|
||||||
import { captureCurrentSession } from './session-snapshot';
|
import { captureCurrentSession } from './session-snapshot';
|
||||||
|
|
||||||
// Debounce window for autosave. A full-tab snapshot + storage write runs on
|
// Debounce window for autosave. A full-tab snapshot + storage write runs on
|
||||||
@@ -16,44 +18,44 @@ export class AutoSaveManager {
|
|||||||
readonly autoSaveHandler = async (): Promise<void> => {
|
readonly autoSaveHandler = async (): Promise<void> => {
|
||||||
await this.scheduleAutoSave();
|
await this.scheduleAutoSave();
|
||||||
};
|
};
|
||||||
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise<void> => {
|
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: TabUpdateInfo = {}): Promise<void> => {
|
||||||
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
|
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
|
||||||
if (!("url" in changeInfo)) return;
|
if (!("url" in changeInfo)) return;
|
||||||
await this.scheduleAutoSave();
|
await this.scheduleAutoSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
async setEnabled(enabled: boolean) {
|
async setEnabled(enabled: boolean) {
|
||||||
await chrome.storage.local.set({ autoSave: enabled });
|
await api.storage.local.set({ autoSave: enabled });
|
||||||
chrome.tabs.onCreated.removeListener(this.autoSaveHandler);
|
api.tabs.onCreated.removeListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onRemoved.removeListener(this.autoSaveHandler);
|
api.tabs.onRemoved.removeListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onMoved.removeListener(this.autoSaveHandler);
|
api.tabs.onMoved.removeListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onAttached.removeListener(this.autoSaveHandler);
|
api.tabs.onAttached.removeListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onDetached.removeListener(this.autoSaveHandler);
|
api.tabs.onDetached.removeListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
|
api.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
|
||||||
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(this.autoSaveHandler);
|
tabGroupsOnUpdated()?.removeListener(this.autoSaveHandler);
|
||||||
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
|
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
|
||||||
this.autoSaveTimer = null;
|
this.autoSaveTimer = null;
|
||||||
this.autoSavePending = false;
|
this.autoSavePending = false;
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
chrome.tabs.onCreated.addListener(this.autoSaveHandler);
|
api.tabs.onCreated.addListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onRemoved.addListener(this.autoSaveHandler);
|
api.tabs.onRemoved.addListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onMoved.addListener(this.autoSaveHandler);
|
api.tabs.onMoved.addListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onAttached.addListener(this.autoSaveHandler);
|
api.tabs.onAttached.addListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onDetached.addListener(this.autoSaveHandler);
|
api.tabs.onDetached.addListener(this.autoSaveHandler);
|
||||||
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
|
api.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
|
||||||
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler);
|
tabGroupsOnUpdated()?.addListener(this.autoSaveHandler);
|
||||||
}
|
}
|
||||||
return { enabled };
|
return { enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveAutoSessionIfChanged() {
|
private async saveAutoSessionIfChanged() {
|
||||||
const { session, signature, tabCount } = await captureCurrentSession();
|
const { session, signature, tabCount } = await captureCurrentSession();
|
||||||
const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature");
|
const { autoSaveSignature } = await api.storage.local.get("autoSaveSignature");
|
||||||
if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount };
|
if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount };
|
||||||
|
|
||||||
const sessions = await getSessions();
|
const sessions = await getSessions();
|
||||||
sessions.__auto__ = session;
|
sessions.__auto__ = session;
|
||||||
await chrome.storage.local.set({ sessions, autoSaveSignature: signature });
|
await api.storage.local.set({ sessions, autoSaveSignature: signature });
|
||||||
return { skipped: false, tabs: tabCount };
|
return { skipped: false, tabs: tabCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ export class AutoSaveManager {
|
|||||||
}
|
}
|
||||||
this.autoSaveInFlight = true;
|
this.autoSaveInFlight = true;
|
||||||
try {
|
try {
|
||||||
const { autoSave } = await chrome.storage.local.get("autoSave");
|
const { autoSave } = await api.storage.local.get("autoSave");
|
||||||
if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged());
|
if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged());
|
||||||
} finally {
|
} finally {
|
||||||
this.autoSaveInFlight = false;
|
this.autoSaveInFlight = false;
|
||||||
@@ -76,7 +78,7 @@ export class AutoSaveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) {
|
private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) {
|
||||||
const { autoSave } = await chrome.storage.local.get("autoSave");
|
const { autoSave } = await api.storage.local.get("autoSave");
|
||||||
if (!autoSave) return;
|
if (!autoSave) return;
|
||||||
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
|
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
|
||||||
this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs);
|
this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs);
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { executeScript, isScriptableUrl, resolveTabUrl } from '../core';
|
import { executeScript, isScriptableUrl, resolveTabUrl } from '../core';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
import type { Json, StorageGetArgs, StorageSetArgs, CookiesListArgs, CookiesGetArgs, CookiesSetArgs } from '../types';
|
import type { Json, StorageGetArgs, StorageSetArgs } from '../types';
|
||||||
|
|
||||||
export class BrowserDataCommands extends CommandGroup {
|
export class BrowserDataCommands extends CommandGroup {
|
||||||
readonly namespace = "storage";
|
readonly namespace = "storage";
|
||||||
readonly commands: Record<string, CommandEntry> = {
|
readonly commands: Record<string, CommandEntry> = {
|
||||||
"storage.get": (a: StorageGetArgs) => this.storageGet(a),
|
"storage.get": (a: StorageGetArgs) => this.storageGet(a),
|
||||||
"storage.set": (a: StorageSetArgs) => this.storageSet(a),
|
"storage.set": (a: StorageSetArgs) => this.storageSet(a),
|
||||||
"cookies.list": (a: CookiesListArgs) => this.cookiesList(a),
|
|
||||||
"cookies.get": (a: CookiesGetArgs) => this.cookiesGet(a),
|
|
||||||
"cookies.set": (a: CookiesSetArgs) => this.cookiesSet(a),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private async storageGet({ key, type = "local", tabId }: StorageGetArgs = {}) {
|
private async storageGet({ key, type = "local", tabId }: StorageGetArgs = {}) {
|
||||||
@@ -49,26 +46,4 @@ export class BrowserDataCommands extends CommandGroup {
|
|||||||
return results[0]?.result ?? false;
|
return results[0]?.result ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cookiesList({ url, domain, name }: CookiesListArgs = {}) {
|
|
||||||
const details: chrome.cookies.GetAllDetails = {};
|
|
||||||
if (url) details.url = url;
|
|
||||||
if (domain) details.domain = domain;
|
|
||||||
if (name) details.name = name;
|
|
||||||
return await chrome.cookies.getAll(details);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async cookiesGet({ url, name }: CookiesGetArgs) {
|
|
||||||
return await chrome.cookies.get({ url, name });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite }: CookiesSetArgs = {}) {
|
|
||||||
const details: chrome.cookies.SetDetails = { 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { webExtApi as api } from '../browser-api';
|
||||||
|
import type { Tab } from '../types';
|
||||||
import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core';
|
import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types';
|
import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types';
|
||||||
|
|
||||||
function fallbackForErrorPageDomOp(funcName: string, tab: chrome.tabs.Tab): Serializable {
|
function fallbackForErrorPageDomOp(funcName: string, tab: Tab): Serializable {
|
||||||
switch (funcName) {
|
switch (funcName) {
|
||||||
case "domExists":
|
case "domExists":
|
||||||
return false;
|
return false;
|
||||||
@@ -105,7 +107,10 @@ export class DomCommands extends CommandGroup {
|
|||||||
const results = await executeScript({
|
const results = await executeScript({
|
||||||
target: { tabId: tab.id },
|
target: { tabId: tab.id },
|
||||||
world: "MAIN",
|
world: "MAIN",
|
||||||
func: (c: string) => (0, eval)(c),
|
func: (c: string) => {
|
||||||
|
const evaluate = globalThis["eval" as keyof typeof globalThis] as (source: string) => unknown;
|
||||||
|
return evaluate(c);
|
||||||
|
},
|
||||||
args: [code],
|
args: [code],
|
||||||
});
|
});
|
||||||
return results[0]?.result ?? null;
|
return results[0]?.result ?? null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { webExtApi as api } from '../browser-api';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
|
|
||||||
@@ -5,8 +6,39 @@ export class ExtensionCommands extends CommandGroup {
|
|||||||
readonly namespace = "extension";
|
readonly namespace = "extension";
|
||||||
readonly commands: Record<string, CommandEntry> = {
|
readonly commands: Record<string, CommandEntry> = {
|
||||||
"extension.reload": () => {
|
"extension.reload": () => {
|
||||||
setTimeout(() => chrome.runtime.reload(), 200);
|
setTimeout(() => api.runtime.reload(), 200);
|
||||||
return { reloading: true };
|
return { reloading: true };
|
||||||
},
|
},
|
||||||
|
"extension.info": () => this.extensionInfo(),
|
||||||
|
"extension.capabilities": () => this.capabilities(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private capabilities() {
|
||||||
|
return [
|
||||||
|
"extension.info",
|
||||||
|
"extension.capabilities",
|
||||||
|
"navigate.open.focus",
|
||||||
|
"navigate.open.background",
|
||||||
|
"tabs.close.tabIds",
|
||||||
|
"tabs.merge_windows.audibleAware",
|
||||||
|
"session.export",
|
||||||
|
"session.import",
|
||||||
|
"jobs.progress",
|
||||||
|
"jobs.cancel",
|
||||||
|
"content-dispatch.bundle",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private extensionInfo() {
|
||||||
|
const manifest = api.runtime.getManifest();
|
||||||
|
return {
|
||||||
|
id: api.runtime.id,
|
||||||
|
name: manifest.name,
|
||||||
|
version: manifest.version,
|
||||||
|
manifestVersion: manifest.manifest_version,
|
||||||
|
browser: navigator.userAgent,
|
||||||
|
platform: navigator.platform,
|
||||||
|
capabilities: this.capabilities(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, processInBatches, resolveGroupId, runLargeOperation, tabInfo } from '../core';
|
import { webExtApi as api } from '../browser-api';
|
||||||
|
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, getTabGroup, groupTabs, moveTabGroup, processInBatches, queryTabGroups, resolveGroupId, runLargeOperation, tabInfo, ungroupTabs, updateTabGroup } from '../core';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types';
|
import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types';
|
||||||
@@ -17,8 +18,8 @@ export class GroupsCommands extends CommandGroup {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private async groupList() {
|
private async groupList() {
|
||||||
const groups = await chrome.tabGroups.query({});
|
const groups = await queryTabGroups({});
|
||||||
const all = await chrome.tabs.query({});
|
const all = await api.tabs.query({});
|
||||||
return groups.map(g => ({
|
return groups.map(g => ({
|
||||||
id: g.id,
|
id: g.id,
|
||||||
title: g.title,
|
title: g.title,
|
||||||
@@ -30,58 +31,58 @@ export class GroupsCommands extends CommandGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async groupTabs({ groupId }: GroupTabsArgs) {
|
private async groupTabs({ groupId }: GroupTabsArgs) {
|
||||||
const all = await chrome.tabs.query({});
|
const all = await api.tabs.query({});
|
||||||
return all.filter(t => t.groupId === groupId).map(tabInfo);
|
return all.filter(t => t.groupId === groupId).map(tabInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async groupCount() {
|
private async groupCount() {
|
||||||
const groups = await chrome.tabGroups.query({});
|
const groups = await queryTabGroups({});
|
||||||
return groups.length;
|
return groups.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async groupQuery({ search }: GroupQueryArgs) {
|
private async groupQuery({ search }: GroupQueryArgs) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const groups = await chrome.tabGroups.query({});
|
const groups = await queryTabGroups({});
|
||||||
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
|
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) {
|
private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) {
|
||||||
return runLargeOperation("group.close", async () => {
|
return runLargeOperation("group.close", async () => {
|
||||||
const tabs = await chrome.tabs.query({});
|
const tabs = await api.tabs.query({});
|
||||||
const groupTabs = tabs.filter(t => t.groupId === groupId);
|
const groupTabs = tabs.filter(t => t.groupId === groupId);
|
||||||
const tabIds = groupTabs.map(t => t.id);
|
const tabIds = groupTabs.map(t => t.id);
|
||||||
const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
|
const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
|
||||||
await processInBatches(tabIds, throttle, batch => chrome.tabs.ungroup(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
|
await processInBatches(tabIds, throttle, batch => ungroupTabs(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
|
||||||
return { groupId, gentle: throttle.gentle, audible: throttle.audible };
|
return { groupId, gentle: throttle.gentle, audible: throttle.audible };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async groupOpen({ name }: GroupOpenArgs) {
|
private async groupOpen({ name }: GroupOpenArgs) {
|
||||||
const tab = await chrome.tabs.create({ active: true });
|
const tab = await api.tabs.create({ active: true });
|
||||||
const groupId = await chrome.tabs.group({ tabIds: asTabIds([tab.id]) });
|
const groupId = await groupTabs({ tabIds: asTabIds([tab.id]) });
|
||||||
await chrome.tabGroups.update(groupId, { title: name });
|
await updateTabGroup(groupId, { title: name });
|
||||||
return { id: groupId, name };
|
return { id: groupId, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async groupAddTab({ group, url }: GroupAddTabArgs) {
|
private async groupAddTab({ group, url }: GroupAddTabArgs) {
|
||||||
const groupId = await resolveGroupId(group);
|
const groupId = await resolveGroupId(group);
|
||||||
const existingTabs = await chrome.tabs.query({ groupId });
|
const existingTabs = await api.tabs.query({ groupId });
|
||||||
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
|
const tab = await api.tabs.create({ url: url || "chrome://newtab/", active: true });
|
||||||
await chrome.tabs.group({ tabIds: asTabIds([tab.id]), groupId });
|
await groupTabs({ tabIds: asTabIds([tab.id]), groupId });
|
||||||
// If a URL was provided, close any blank placeholder tabs left from group creation
|
// If a URL was provided, close any blank placeholder tabs left from group creation
|
||||||
if (url) {
|
if (url) {
|
||||||
const placeholders = existingTabs.filter(t =>
|
const placeholders = existingTabs.filter(t =>
|
||||||
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
|
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
|
||||||
);
|
);
|
||||||
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
|
if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
|
||||||
}
|
}
|
||||||
return { tabId: tab.id, groupId };
|
return { tabId: tab.id, groupId };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async groupMove({ group, forward, backward }: GroupMoveArgs) {
|
private async groupMove({ group, forward, backward }: GroupMoveArgs) {
|
||||||
const groupId = await resolveGroupId(group);
|
const groupId = await resolveGroupId(group);
|
||||||
const groupInfo = await chrome.tabGroups.get(groupId);
|
const groupInfo = await getTabGroup(groupId);
|
||||||
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
|
const allTabs = await api.tabs.query({ windowId: groupInfo.windowId });
|
||||||
allTabs.sort((a, b) => a.index - b.index);
|
allTabs.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
const blocks = buildTabBlocks(allTabs);
|
const blocks = buildTabBlocks(allTabs);
|
||||||
@@ -98,7 +99,7 @@ export class GroupsCommands extends CommandGroup {
|
|||||||
nextBlock.groupId === null
|
nextBlock.groupId === null
|
||||||
? currentBlock.startIndex + 1
|
? currentBlock.startIndex + 1
|
||||||
: nextBlock.endIndex - currentLength + 1;
|
: nextBlock.endIndex - currentLength + 1;
|
||||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
await moveTabGroup(groupId, { index: targetIndex });
|
||||||
} else if (backward) {
|
} else if (backward) {
|
||||||
const previousBlock = blocks[currentIdx - 1];
|
const previousBlock = blocks[currentIdx - 1];
|
||||||
if (!previousBlock) return { groupId, moved: false };
|
if (!previousBlock) return { groupId, moved: false };
|
||||||
@@ -106,7 +107,7 @@ export class GroupsCommands extends CommandGroup {
|
|||||||
previousBlock.groupId === null
|
previousBlock.groupId === null
|
||||||
? currentBlock.startIndex - 1
|
? currentBlock.startIndex - 1
|
||||||
: previousBlock.startIndex;
|
: previousBlock.startIndex;
|
||||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
await moveTabGroup(groupId, { index: targetIndex });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { groupId, moved: true };
|
return { groupId, moved: true };
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core';
|
import { webExtApi as api } from '../browser-api';
|
||||||
|
import type { Tab } from '../types';
|
||||||
|
import { getActiveTab, getAliases, groupTabs as groupTabIds, isBrowserErrorUrl, resolveGroupId, tabInfo, updateTabGroup } from '../core';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types';
|
import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types';
|
||||||
@@ -17,7 +19,7 @@ export class NavigationCommands extends CommandGroup {
|
|||||||
"navigate.open_wait": (a: NavOpenWaitArgs) => this.navOpenWait(a),
|
"navigate.open_wait": (a: NavOpenWaitArgs) => this.navOpenWait(a),
|
||||||
};
|
};
|
||||||
|
|
||||||
private async navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }: NavOpenArgs) {
|
private async navOpen({ url, background, focus, window: windowName, windowId: explicitWindowId, group: groupNameOrId }: NavOpenArgs) {
|
||||||
let windowId: number | undefined;
|
let windowId: number | undefined;
|
||||||
if (explicitWindowId != null) {
|
if (explicitWindowId != null) {
|
||||||
windowId = explicitWindowId;
|
windowId = explicitWindowId;
|
||||||
@@ -26,34 +28,57 @@ export class NavigationCommands extends CommandGroup {
|
|||||||
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
|
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
|
||||||
if (entry) windowId = parseInt(entry[0]);
|
if (entry) windowId = parseInt(entry[0]);
|
||||||
}
|
}
|
||||||
const tab = await chrome.tabs.create({ url, active: !background, windowId });
|
const tab = await api.tabs.create({ url, active: Boolean(focus) && !background, windowId });
|
||||||
if (groupNameOrId != null) {
|
if (groupNameOrId != null) {
|
||||||
let groupId;
|
let groupId;
|
||||||
try {
|
try {
|
||||||
groupId = await resolveGroupId(groupNameOrId);
|
groupId = await resolveGroupId(groupNameOrId);
|
||||||
// Close any blank placeholder tabs that were created when the group was made
|
// Close any blank placeholder tabs that were created when the group was made
|
||||||
const groupTabs = await chrome.tabs.query({ groupId });
|
const groupTabs = await api.tabs.query({ groupId });
|
||||||
const placeholders = groupTabs.filter(t =>
|
const placeholders = groupTabs.filter(t =>
|
||||||
t.id !== tab.id &&
|
t.id !== tab.id &&
|
||||||
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
|
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
|
||||||
);
|
);
|
||||||
await chrome.tabs.group({ tabIds: [tab.id], groupId });
|
await groupTabIds({ tabIds: [tab.id], groupId });
|
||||||
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
|
if (placeholders.length) await api.tabs.remove(placeholders.map(t => t.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e;
|
if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e;
|
||||||
// Group doesn't exist — create it with the tab already in it
|
// Group doesn't exist — create it with the tab already in it
|
||||||
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
groupId = await groupTabIds({ tabIds: [tab.id] });
|
||||||
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
|
await updateTabGroup(groupId, { title: String(groupNameOrId) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { id: tab.id, url: tab.url };
|
const loadedTab = await this.waitForOpenedTabUrl(tab.id, url, tab);
|
||||||
|
return { id: loadedTab.id, url: loadedTab.url || loadedTab.pendingUrl || url };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForOpenedTabUrl(tabId: number, targetUrl: string, initialTab: Tab): Promise<Tab> {
|
||||||
|
const initialUrl = initialTab.url || initialTab.pendingUrl || "";
|
||||||
|
if (this.isOpenedTabUrlReady(initialUrl, targetUrl)) return initialTab;
|
||||||
|
|
||||||
|
const deadline = Date.now() + 2000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const current = await api.tabs.get(tabId);
|
||||||
|
const currentUrl = current.url || current.pendingUrl || "";
|
||||||
|
if (this.isOpenedTabUrlReady(currentUrl, targetUrl)) return current;
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.tabs.get(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOpenedTabUrlReady(currentUrl: string, targetUrl: string): boolean {
|
||||||
|
if (!currentUrl) return false;
|
||||||
|
if (currentUrl === targetUrl || currentUrl.startsWith(targetUrl)) return true;
|
||||||
|
if (targetUrl === "about:blank" || targetUrl === "chrome://newtab/") return currentUrl === targetUrl;
|
||||||
|
return currentUrl !== "about:blank" && currentUrl !== "chrome://newtab/";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async navTo({ tabId, url }: NavToArgs) {
|
private async navTo({ tabId, url }: NavToArgs) {
|
||||||
const tab = await chrome.tabs.update(tabId, { url });
|
const tab = await api.tabs.update(tabId, { url });
|
||||||
const deadline = Date.now() + 1000;
|
const deadline = Date.now() + 1000;
|
||||||
while (tabId && Date.now() < deadline) {
|
while (tabId && Date.now() < deadline) {
|
||||||
const current = await chrome.tabs.get(tabId);
|
const current = await api.tabs.get(tabId);
|
||||||
const currentUrl = current.url || current.pendingUrl || "";
|
const currentUrl = current.url || current.pendingUrl || "";
|
||||||
if (currentUrl === url || currentUrl.startsWith(url)) {
|
if (currentUrl === url || currentUrl.startsWith(url)) {
|
||||||
return { id: current.id, url: currentUrl };
|
return { id: current.id, url: currentUrl };
|
||||||
@@ -65,35 +90,35 @@ export class NavigationCommands extends CommandGroup {
|
|||||||
|
|
||||||
private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) {
|
private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) {
|
||||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||||
await chrome.tabs.reload(tab.id, { bypassCache });
|
await api.tabs.reload(tab.id, { bypassCache });
|
||||||
return { tabId: tab.id };
|
return { tabId: tab.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async navBack({ tabId }: NavTabArgs) {
|
private async navBack({ tabId }: NavTabArgs) {
|
||||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||||
await chrome.tabs.goBack(tab.id);
|
await api.tabs.goBack(tab.id);
|
||||||
return { tabId: tab.id };
|
return { tabId: tab.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async navForward({ tabId }: NavTabArgs) {
|
private async navForward({ tabId }: NavTabArgs) {
|
||||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||||
await chrome.tabs.goForward(tab.id);
|
await api.tabs.goForward(tab.id);
|
||||||
return { tabId: tab.id };
|
return { tabId: tab.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async navFocus({ pattern }: NavFocusArgs) {
|
private async navFocus({ pattern }: NavFocusArgs) {
|
||||||
// If pattern is a plain integer, treat it as a tab ID
|
// If pattern is a plain integer, treat it as a tab ID
|
||||||
const asInt = parseInt(pattern);
|
const asInt = parseInt(pattern);
|
||||||
let match: chrome.tabs.Tab | undefined;
|
let match: Tab | undefined;
|
||||||
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
|
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
|
||||||
match = await chrome.tabs.get(asInt);
|
match = await api.tabs.get(asInt);
|
||||||
} else {
|
} else {
|
||||||
const all = await chrome.tabs.query({});
|
const all = await api.tabs.query({});
|
||||||
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
|
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
|
||||||
}
|
}
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
await chrome.windows.update(match.windowId, { focused: true });
|
await api.windows.update(match.windowId, { focused: true });
|
||||||
await chrome.tabs.update(match.id, { active: true });
|
await api.tabs.update(match.id, { active: true });
|
||||||
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
|
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +127,7 @@ export class NavigationCommands extends CommandGroup {
|
|||||||
const deadline = Date.now() + timeout;
|
const deadline = Date.now() + timeout;
|
||||||
const interval = 200;
|
const interval = 200;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const t = await chrome.tabs.get(tab.id);
|
const t = await api.tabs.get(tab.id);
|
||||||
const currentUrl = t.url || t.pendingUrl || "";
|
const currentUrl = t.url || t.pendingUrl || "";
|
||||||
if (isBrowserErrorUrl(currentUrl)) {
|
if (isBrowserErrorUrl(currentUrl)) {
|
||||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
|
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
|
||||||
@@ -115,8 +140,8 @@ export class NavigationCommands extends CommandGroup {
|
|||||||
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
|
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async navOpenWait({ url, timeout = 30000, background, window: windowName, group }: NavOpenWaitArgs = {}) {
|
private async navOpenWait({ url, timeout = 30000, background, focus, window: windowName, group }: NavOpenWaitArgs = {}) {
|
||||||
const opened = await this.navOpen({ url, background, window: windowName, group });
|
const opened = await this.navOpen({ url, background, focus, window: windowName, group });
|
||||||
return await this.navWait({ tabId: opened.id, timeout });
|
return await this.navWait({ tabId: opened.id, timeout });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { normalizeGroupColor } from '../core';
|
import { webExtApi as api } from '../browser-api';
|
||||||
|
import type { Tab, TabGroup } from '../types';
|
||||||
|
import { normalizeGroupColor, queryTabGroups } from '../core';
|
||||||
import type { SessionTab, StoredSession } from '../types';
|
import type { SessionTab, StoredSession } from '../types';
|
||||||
|
|
||||||
export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tabGroups.TabGroup[]): SessionTab[] {
|
export function buildSessionSnapshot(tabs: Tab[], groups: TabGroup[]): SessionTab[] {
|
||||||
const groupById = new Map(groups.map(group => [group.id, group]));
|
const groupById = new Map(groups.map(group => [group.id, group]));
|
||||||
return tabs
|
return tabs
|
||||||
.filter(tab => Boolean(tab.url || tab.pendingUrl))
|
.filter(tab => Boolean(tab.url || tab.pendingUrl))
|
||||||
@@ -27,8 +29,8 @@ export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tab
|
|||||||
* its change-detection signature. Shared by session.save and the autosave path.
|
* its change-detection signature. Shared by session.save and the autosave path.
|
||||||
*/
|
*/
|
||||||
export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
|
export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
|
||||||
const tabs = await chrome.tabs.query({});
|
const tabs = await api.tabs.query({});
|
||||||
const groups = await chrome.tabGroups.query({});
|
const groups = await queryTabGroups({});
|
||||||
const sessionTabs = buildSessionSnapshot(tabs, groups);
|
const sessionTabs = buildSessionSnapshot(tabs, groups);
|
||||||
const signature = sessionSignature(sessionTabs);
|
const signature = sessionSignature(sessionTabs);
|
||||||
const session: StoredSession = {
|
const session: StoredSession = {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
import { webExtApi as api } from '../browser-api';
|
||||||
|
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, groupTabs, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, updateTabGroup, yieldForLargeOperation } from '../core';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import { AutoSaveManager } from './autosave';
|
import { AutoSaveManager } from './autosave';
|
||||||
import { captureCurrentSession } from './session-snapshot';
|
import { captureCurrentSession } from './session-snapshot';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
import type { LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs } from '../types';
|
import type { Json, LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionImportArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs, StoredSession } from '../types';
|
||||||
|
|
||||||
function lazyPlaceholderUrl(url: string) {
|
function lazyPlaceholderUrl(url: string) {
|
||||||
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch]));
|
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch]));
|
||||||
@@ -19,6 +20,8 @@ export class SessionCommands extends CommandGroup {
|
|||||||
"session.list": () => this.sessionList(),
|
"session.list": () => this.sessionList(),
|
||||||
"session.remove": (a: SessionRemoveArgs) => this.sessionRemove(a),
|
"session.remove": (a: SessionRemoveArgs) => this.sessionRemove(a),
|
||||||
"session.diff": (a: SessionDiffArgs) => this.sessionDiff(a),
|
"session.diff": (a: SessionDiffArgs) => this.sessionDiff(a),
|
||||||
|
"session.export": (a: SessionSaveArgs) => this.sessionExport(a),
|
||||||
|
"session.import": (a: SessionImportArgs) => this.sessionImport(a),
|
||||||
"session.auto_save": (a: SessionAutoSaveArgs) => this.autoSaveManager.setEnabled(Boolean(a.enabled)),
|
"session.auto_save": (a: SessionAutoSaveArgs) => this.autoSaveManager.setEnabled(Boolean(a.enabled)),
|
||||||
"clients.list": () => this.clientsList(),
|
"clients.list": () => this.clientsList(),
|
||||||
"clients.rename_profile": (a: ClientsRenameProfileArgs) => this.clientsRenameProfile(a),
|
"clients.rename_profile": (a: ClientsRenameProfileArgs) => this.clientsRenameProfile(a),
|
||||||
@@ -30,18 +33,18 @@ export class SessionCommands extends CommandGroup {
|
|||||||
const { session, tabCount } = await captureCurrentSession();
|
const { session, tabCount } = await captureCurrentSession();
|
||||||
const sessions = await getSessions();
|
const sessions = await getSessions();
|
||||||
sessions[name] = session;
|
sessions[name] = session;
|
||||||
await chrome.storage.local.set({ sessions });
|
await api.storage.local.set({ sessions });
|
||||||
return { name, tabs: tabCount };
|
return { name, tabs: tabCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public: invoked from index.ts on chrome.tabs.onActivated.
|
// Public: invoked from index.ts on api.tabs.onActivated.
|
||||||
async activateLazyTab(tabId: number | string) {
|
async activateLazyTab(tabId: number | string) {
|
||||||
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
|
const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
|
||||||
const entry = lazySessionTabs?.[tabId];
|
const entry = lazySessionTabs?.[tabId];
|
||||||
if (!entry?.url) return false;
|
if (!entry?.url) return false;
|
||||||
delete lazySessionTabs[tabId];
|
delete lazySessionTabs[tabId];
|
||||||
await chrome.storage.local.set({ lazySessionTabs });
|
await api.storage.local.set({ lazySessionTabs });
|
||||||
await chrome.tabs.update(Number(tabId), { url: entry.url });
|
await api.tabs.update(Number(tabId), { url: entry.url });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,24 +59,24 @@ export class SessionCommands extends CommandGroup {
|
|||||||
const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode);
|
const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode);
|
||||||
const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
|
const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
|
||||||
const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length;
|
const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length;
|
||||||
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
|
const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
|
||||||
const lazyMap: LazySessionMap = lazySessionTabs || {};
|
const lazyMap: LazySessionMap = lazySessionTabs || {};
|
||||||
updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length });
|
updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length });
|
||||||
|
|
||||||
for (const [idx, entry] of sessionTabs.entries()) {
|
for (const [idx, entry] of sessionTabs.entries()) {
|
||||||
throwIfJobCancelled(__job);
|
throwIfJobCancelled(__job);
|
||||||
const shouldLazy = lazy && idx >= eagerLimit;
|
const shouldLazy = lazy && idx >= eagerLimit;
|
||||||
const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) });
|
const tab = await api.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) });
|
||||||
createdTabs.push({ tabId: tab.id, entry });
|
createdTabs.push({ tabId: tab.id, entry });
|
||||||
if (shouldLazy) {
|
if (shouldLazy) {
|
||||||
lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() };
|
lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() };
|
||||||
} else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) {
|
} else if (discardBackgroundTabs && !entry.pinned && api.tabs.discard) {
|
||||||
try { await chrome.tabs.discard(tab.id); } catch (_) {}
|
try { await api.tabs.discard(tab.id); } catch (_) {}
|
||||||
}
|
}
|
||||||
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
|
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
|
||||||
await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs));
|
await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs));
|
||||||
}
|
}
|
||||||
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap });
|
if (lazy) await api.storage.local.set({ lazySessionTabs: lazyMap });
|
||||||
|
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const { tabId, entry } of createdTabs) {
|
for (const { tabId, entry } of createdTabs) {
|
||||||
@@ -89,8 +92,8 @@ export class SessionCommands extends CommandGroup {
|
|||||||
updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size });
|
updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size });
|
||||||
for (const { meta, tabIds } of groups.values()) {
|
for (const { meta, tabIds } of groups.values()) {
|
||||||
throwIfJobCancelled(__job);
|
throwIfJobCancelled(__job);
|
||||||
const restoredGroupId = await chrome.tabs.group({ tabIds });
|
const restoredGroupId = await groupTabs({ tabIds });
|
||||||
await chrome.tabGroups.update(restoredGroupId, {
|
await updateTabGroup(restoredGroupId, {
|
||||||
title: meta.title || "",
|
title: meta.title || "",
|
||||||
color: normalizeGroupColor(meta.color),
|
color: normalizeGroupColor(meta.color),
|
||||||
collapsed: Boolean(meta.collapsed),
|
collapsed: Boolean(meta.collapsed),
|
||||||
@@ -117,7 +120,7 @@ export class SessionCommands extends CommandGroup {
|
|||||||
const sessions = await getSessions();
|
const sessions = await getSessions();
|
||||||
if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
|
if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
|
||||||
delete sessions[name];
|
delete sessions[name];
|
||||||
await chrome.storage.local.set({ sessions });
|
await api.storage.local.set({ sessions });
|
||||||
return { name };
|
return { name };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,12 +134,39 @@ export class SessionCommands extends CommandGroup {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sessionExport({ name }: SessionSaveArgs) {
|
||||||
|
const sessions = await getSessions();
|
||||||
|
if (!name) return { sessions };
|
||||||
|
const session = sessions[name];
|
||||||
|
if (!session) throw new Error(`Session '${name}' not found`);
|
||||||
|
return { name, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSession(value: Json | undefined): boolean {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const candidate = value as object as StoredSession;
|
||||||
|
return Array.isArray(candidate.tabs) || Array.isArray(candidate.urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sessionImport({ name, session, overwrite = false }: SessionImportArgs) {
|
||||||
|
if (!name) throw new Error("Session name is required");
|
||||||
|
if (!this.isSession(session)) throw new Error("Session payload must contain tabs or urls");
|
||||||
|
const sessions = await getSessions();
|
||||||
|
if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`);
|
||||||
|
const stored = session as object as StoredSession;
|
||||||
|
sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() };
|
||||||
|
await api.storage.local.set({ sessions });
|
||||||
|
return { name, tabs: getSessionTabs(sessions[name]).length };
|
||||||
|
}
|
||||||
|
|
||||||
private async clientsList() {
|
private async clientsList() {
|
||||||
const manifest = chrome.runtime.getManifest();
|
const manifest = api.runtime.getManifest();
|
||||||
const alias = await getProfileAlias();
|
const alias = await getProfileAlias();
|
||||||
|
const browserInfo = api.runtime.getBrowserInfo ? await api.runtime.getBrowserInfo() : null;
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
return [{
|
return [{
|
||||||
name: "Chrome",
|
name: browserInfo?.name || (userAgent.includes("Firefox/") ? "Firefox" : "Chrome"),
|
||||||
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown",
|
version: browserInfo?.version || userAgent.match(/(?:Chrome|Firefox)\/([\d.]+)/)?.[1] || "unknown",
|
||||||
platform: navigator.platform,
|
platform: navigator.platform,
|
||||||
extensionVersion: manifest.version,
|
extensionVersion: manifest.version,
|
||||||
profile: alias,
|
profile: alias,
|
||||||
@@ -144,7 +174,7 @@ export class SessionCommands extends CommandGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) {
|
private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) {
|
||||||
await chrome.storage.local.set({ profileAlias: alias });
|
await api.storage.local.set({ profileAlias: alias });
|
||||||
return { alias };
|
return { alias };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { webExtApi as api } from '../browser-api';
|
||||||
import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core';
|
import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
@@ -17,7 +18,7 @@ export class TabsQueryCommands extends CommandGroup {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private async tabsList() {
|
private async tabsList() {
|
||||||
const windows = await chrome.windows.getAll({ populate: true });
|
const windows = await api.windows.getAll({ populate: true });
|
||||||
const aliases = await getAliases();
|
const aliases = await getAliases();
|
||||||
const tabs = [];
|
const tabs = [];
|
||||||
for (const w of windows) {
|
for (const w of windows) {
|
||||||
@@ -34,7 +35,7 @@ export class TabsQueryCommands extends CommandGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) {
|
private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) {
|
||||||
const activeTabs = await chrome.tabs.query({ windowId, active: true });
|
const activeTabs = await api.tabs.query({ windowId, active: true });
|
||||||
const tab = activeTabs[0];
|
const tab = activeTabs[0];
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
throw new Error(`No active tab found for window ${windowId}`);
|
throw new Error(`No active tab found for window ${windowId}`);
|
||||||
@@ -43,24 +44,24 @@ export class TabsQueryCommands extends CommandGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async tabsStatus({ tabId }: TabIdArgs) {
|
private async tabsStatus({ tabId }: TabIdArgs) {
|
||||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
|
||||||
return tabInfo(tab);
|
return tabInfo(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsFilter({ pattern }: TabsPatternArgs) {
|
private async tabsFilter({ pattern }: TabsPatternArgs) {
|
||||||
const all = await chrome.tabs.query({});
|
const all = await api.tabs.query({});
|
||||||
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
|
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsCount({ pattern }: TabsPatternArgs) {
|
private async tabsCount({ pattern }: TabsPatternArgs) {
|
||||||
const all = await chrome.tabs.query({});
|
const all = await api.tabs.query({});
|
||||||
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
|
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
|
||||||
return all.length;
|
return all.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsQuery({ search }: TabsQueryArgs) {
|
private async tabsQuery({ search }: TabsQueryArgs) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const all = await chrome.tabs.query({});
|
const all = await api.tabs.query({});
|
||||||
return all.filter(t =>
|
return all.filter(t =>
|
||||||
(t.url && t.url.toLowerCase().includes(q)) ||
|
(t.url && t.url.toLowerCase().includes(q)) ||
|
||||||
(t.title && t.title.toLowerCase().includes(q))
|
(t.title && t.title.toLowerCase().includes(q))
|
||||||
@@ -68,7 +69,7 @@ export class TabsQueryCommands extends CommandGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) {
|
private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) {
|
||||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
|
||||||
const deadline = Date.now() + timeout;
|
const deadline = Date.now() + timeout;
|
||||||
const regex = new RegExp(pattern);
|
const regex = new RegExp(pattern);
|
||||||
let lastUrl = tab.url || tab.pendingUrl || "";
|
let lastUrl = tab.url || tab.pendingUrl || "";
|
||||||
@@ -81,7 +82,7 @@ export class TabsQueryCommands extends CommandGroup {
|
|||||||
if (matches(lastUrl)) return tabInfo(tab);
|
if (matches(lastUrl)) return tabInfo(tab);
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const t = await chrome.tabs.get(tab.id);
|
const t = await api.tabs.get(tab.id);
|
||||||
lastUrl = t.url || t.pendingUrl || "";
|
lastUrl = t.url || t.pendingUrl || "";
|
||||||
lastStatus = t.status || "unknown";
|
lastStatus = t.status || "unknown";
|
||||||
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
|
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { asTabIds, getActiveTab, getLargeOperationThrottle, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
import { webExtApi as api } from '../browser-api';
|
||||||
|
import type { TabMoveProperties, BrowserWindow } from '../types';
|
||||||
|
import { asTabIds, getActiveTab, getLargeOperationThrottle, groupTabs, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types';
|
import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types';
|
||||||
@@ -23,7 +25,7 @@ export class TabsMutationCommands extends CommandGroup {
|
|||||||
return runLargeOperation("tabs.close", async () => {
|
return runLargeOperation("tabs.close", async () => {
|
||||||
let toClose: number[] = [];
|
let toClose: number[] = [];
|
||||||
if (duplicates) {
|
if (duplicates) {
|
||||||
const windows = await chrome.windows.getAll({ populate: true });
|
const windows = await api.windows.getAll({ populate: true });
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const w of windows) {
|
for (const w of windows) {
|
||||||
for (const t of w.tabs || []) {
|
for (const t of w.tabs || []) {
|
||||||
@@ -34,7 +36,7 @@ export class TabsMutationCommands extends CommandGroup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (inactive) {
|
} else if (inactive) {
|
||||||
const all = await chrome.tabs.query({});
|
const all = await api.tabs.query({});
|
||||||
toClose = all.filter(t => !t.active).map(t => t.id);
|
toClose = all.filter(t => !t.active).map(t => t.id);
|
||||||
} else if (tabIds?.length) {
|
} else if (tabIds?.length) {
|
||||||
toClose = tabIds.filter(id => id != null);
|
toClose = tabIds.filter(id => id != null);
|
||||||
@@ -42,17 +44,17 @@ export class TabsMutationCommands extends CommandGroup {
|
|||||||
toClose = [tabId];
|
toClose = [tabId];
|
||||||
}
|
}
|
||||||
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
|
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
|
||||||
await processInBatches(toClose, throttle, batch => chrome.tabs.remove(batch), { job: __job, phase: "closing tabs" });
|
await processInBatches(toClose, throttle, batch => api.tabs.remove(batch), { job: __job, phase: "closing tabs" });
|
||||||
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
|
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) {
|
private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) {
|
||||||
const moveProps: Partial<chrome.tabs.MoveProperties> = {};
|
const moveProps: Partial<TabMoveProperties> = {};
|
||||||
if (windowId != null) moveProps.windowId = windowId;
|
if (windowId != null) moveProps.windowId = windowId;
|
||||||
|
|
||||||
if (forward || backward) {
|
if (forward || backward) {
|
||||||
const tab = await chrome.tabs.get(tabId);
|
const tab = await api.tabs.get(tabId);
|
||||||
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
|
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
|
||||||
else moveProps.index = Math.max(0, tab.index - 1);
|
else moveProps.index = Math.max(0, tab.index - 1);
|
||||||
} else if (index != null) {
|
} else if (index != null) {
|
||||||
@@ -62,17 +64,15 @@ export class TabsMutationCommands extends CommandGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// `index` is always assigned by one of the branches above before this call.
|
// `index` is always assigned by one of the branches above before this call.
|
||||||
await chrome.tabs.move(tabId, moveProps as chrome.tabs.MoveProperties);
|
await api.tabs.move(tabId, moveProps as TabMoveProperties);
|
||||||
if (groupId != null) {
|
if (groupId != null) {
|
||||||
await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId });
|
await groupTabs({ tabIds: asTabIds([tabId]), groupId });
|
||||||
}
|
}
|
||||||
return { tabId };
|
return { tabId };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsActive({ tabId }: TabIdArgs) {
|
private async tabsActive({ tabId }: TabIdArgs) {
|
||||||
const tab = await chrome.tabs.get(tabId);
|
await api.tabs.update(tabId, { active: true });
|
||||||
await chrome.windows.update(tab.windowId, { focused: true });
|
|
||||||
await chrome.tabs.update(tabId, { active: true });
|
|
||||||
return { tabId };
|
return { tabId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export class TabsMutationCommands extends CommandGroup {
|
|||||||
|
|
||||||
private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) {
|
private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) {
|
||||||
return runLargeOperation("tabs.sort", async () => {
|
return runLargeOperation("tabs.sort", async () => {
|
||||||
const windows = await chrome.windows.getAll({ populate: true });
|
const windows = await api.windows.getAll({ populate: true });
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||||
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
|
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
|
||||||
@@ -100,7 +100,7 @@ export class TabsMutationCommands extends CommandGroup {
|
|||||||
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
|
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
throwIfJobCancelled(__job);
|
throwIfJobCancelled(__job);
|
||||||
await chrome.tabs.move(sorted[i].id, { index: i });
|
await api.tabs.move(sorted[i].id, { index: i });
|
||||||
moved++;
|
moved++;
|
||||||
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
|
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
|
||||||
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
|
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
|
||||||
@@ -110,62 +110,69 @@ export class TabsMutationCommands extends CommandGroup {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private windowHasAudibleTabs(window: BrowserWindow): boolean {
|
||||||
|
return Boolean(window.tabs?.some(tab => tab.audible && !tab.mutedInfo?.muted));
|
||||||
|
}
|
||||||
|
|
||||||
private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) {
|
private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) {
|
||||||
return runLargeOperation("tabs.merge_windows", async () => {
|
return runLargeOperation("tabs.merge_windows", async () => {
|
||||||
const current = await chrome.windows.getCurrent();
|
const all = await api.windows.getAll({ populate: true });
|
||||||
const all = await chrome.windows.getAll({ populate: true });
|
const movableWindows = all.filter(w => !this.windowHasAudibleTabs(w));
|
||||||
|
const target = movableWindows.find(w => w.focused) || movableWindows[0];
|
||||||
|
if (!target) return { moved: 0, skippedAudibleWindows: all.length };
|
||||||
|
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
const totalTabs = movableWindows.filter(w => w.id !== target.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||||
updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs });
|
updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs });
|
||||||
for (const w of all) {
|
for (const w of movableWindows) {
|
||||||
if (w.id === current.id) continue;
|
if (w.id === target.id) continue;
|
||||||
const ids = w.tabs.map(t => t.id);
|
const ids = w.tabs.map(t => t.id);
|
||||||
const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
||||||
moved = await processInBatches(ids, throttle,
|
moved = await processInBatches(ids, throttle,
|
||||||
batch => chrome.tabs.move(batch, { windowId: current.id, index: -1 }),
|
batch => api.tabs.move(batch, { windowId: target.id, index: -1 }),
|
||||||
{ job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved });
|
{ job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved });
|
||||||
}
|
}
|
||||||
return { moved };
|
return { moved, skippedAudibleWindows: all.length - movableWindows.length };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsPin({ tabId }: TabIdArgs) {
|
private async tabsPin({ tabId }: TabIdArgs) {
|
||||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
|
||||||
await chrome.tabs.update(tab.id, { pinned: true });
|
await api.tabs.update(tab.id, { pinned: true });
|
||||||
return { tabId: tab.id, pinned: true };
|
return { tabId: tab.id, pinned: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsUnpin({ tabId }: TabIdArgs) {
|
private async tabsUnpin({ tabId }: TabIdArgs) {
|
||||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
const tab = tabId ? await api.tabs.get(tabId) : await getActiveTab();
|
||||||
await chrome.tabs.update(tab.id, { pinned: false });
|
await api.tabs.update(tab.id, { pinned: false });
|
||||||
return { tabId: tab.id, pinned: false };
|
return { tabId: tab.id, pinned: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) {
|
private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) {
|
||||||
let windowId: number | undefined;
|
let windowId: number | undefined;
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
const tab = await chrome.tabs.get(tabId);
|
const tab = await api.tabs.get(tabId);
|
||||||
await chrome.tabs.update(tabId, { active: true });
|
await api.tabs.update(tabId, { active: true });
|
||||||
windowId = tab.windowId;
|
windowId = tab.windowId;
|
||||||
} else {
|
} else {
|
||||||
const tab = await getActiveTab();
|
const tab = await getActiveTab();
|
||||||
windowId = tab.windowId;
|
windowId = tab.windowId;
|
||||||
}
|
}
|
||||||
const opts: chrome.extensionTypes.ImageDetails = { format: format as chrome.extensionTypes.ImageFormat };
|
const opts: browser.extensionTypes.ImageDetails = { format: format as browser.extensionTypes.ImageFormat };
|
||||||
if (format === "jpeg" && quality != null) opts.quality = quality;
|
if (format === "jpeg" && quality != null) opts.quality = quality;
|
||||||
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
|
const dataUrl = await api.tabs.captureVisibleTab(windowId, opts);
|
||||||
return { dataUrl, format };
|
return { dataUrl, format };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsMute({ tabId }: TabIdArgs) {
|
private async tabsMute({ tabId }: TabIdArgs) {
|
||||||
const tab = await resolveTabForDirectAction(tabId, "mute");
|
const tab = await resolveTabForDirectAction(tabId, "mute");
|
||||||
await chrome.tabs.update(tab.id, { muted: true });
|
await api.tabs.update(tab.id, { muted: true });
|
||||||
return { tabId: tab.id, muted: true };
|
return { tabId: tab.id, muted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tabsUnmute({ tabId }: TabIdArgs) {
|
private async tabsUnmute({ tabId }: TabIdArgs) {
|
||||||
const tab = await resolveTabForDirectAction(tabId, "unmute");
|
const tab = await resolveTabForDirectAction(tabId, "unmute");
|
||||||
await chrome.tabs.update(tab.id, { muted: false });
|
await api.tabs.update(tab.id, { muted: false });
|
||||||
return { tabId: tab.id, muted: false };
|
return { tabId: tab.id, muted: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { webExtApi as api } from '../browser-api';
|
||||||
|
import type { WindowCreateData } from '../types';
|
||||||
import { getAliases } from '../core';
|
import { getAliases } from '../core';
|
||||||
import { CommandGroup } from '../classes/CommandGroup';
|
import { CommandGroup } from '../classes/CommandGroup';
|
||||||
import type { CommandEntry } from '../classes/CommandGroup';
|
import type { CommandEntry } from '../classes/CommandGroup';
|
||||||
@@ -13,7 +15,7 @@ export class WindowsCommands extends CommandGroup {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private async windowsList() {
|
private async windowsList() {
|
||||||
const windows = await chrome.windows.getAll({ populate: true });
|
const windows = await api.windows.getAll({ populate: true });
|
||||||
const aliases = await getAliases();
|
const aliases = await getAliases();
|
||||||
return windows.map(w => ({
|
return windows.map(w => ({
|
||||||
id: w.id,
|
id: w.id,
|
||||||
@@ -27,19 +29,19 @@ export class WindowsCommands extends CommandGroup {
|
|||||||
private async windowsRename({ windowId, name }: WindowsRenameArgs) {
|
private async windowsRename({ windowId, name }: WindowsRenameArgs) {
|
||||||
const aliases = await getAliases();
|
const aliases = await getAliases();
|
||||||
aliases[windowId] = name;
|
aliases[windowId] = name;
|
||||||
await chrome.storage.local.set({ windowAliases: aliases });
|
await api.storage.local.set({ windowAliases: aliases });
|
||||||
return { windowId, name };
|
return { windowId, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async windowsClose({ windowId }: WindowsCloseArgs) {
|
private async windowsClose({ windowId }: WindowsCloseArgs) {
|
||||||
await chrome.windows.remove(windowId);
|
await api.windows.remove(windowId);
|
||||||
return { windowId };
|
return { windowId };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async windowsOpen({ url }: WindowsOpenArgs) {
|
private async windowsOpen({ url }: WindowsOpenArgs) {
|
||||||
const createData: chrome.windows.CreateData = { focused: true };
|
const createData: WindowCreateData = { focused: true };
|
||||||
if (url) createData.url = url;
|
if (url) createData.url = url;
|
||||||
const w = await chrome.windows.create(createData);
|
const w = await api.windows.create(createData);
|
||||||
return { id: w.id };
|
return { id: w.id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
import type { ContentArgs } from '../types';
|
|
||||||
|
|
||||||
export function extractMarkdown({ selector }: ContentArgs) {
|
|
||||||
const BLOCKS = new Set([
|
|
||||||
"article", "aside", "blockquote", "body", "div", "dl", "fieldset", "figcaption",
|
|
||||||
"figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr",
|
|
||||||
"li", "main", "nav", "ol", "p", "pre", "section", "table", "tbody", "td", "tfoot",
|
|
||||||
"th", "thead", "tr", "ul"
|
|
||||||
]);
|
|
||||||
const NOISE_SELECTOR = [
|
|
||||||
"script",
|
|
||||||
"style",
|
|
||||||
"noscript",
|
|
||||||
"template",
|
|
||||||
"svg",
|
|
||||||
"canvas",
|
|
||||||
"iframe",
|
|
||||||
"dialog",
|
|
||||||
"button",
|
|
||||||
"input",
|
|
||||||
"textarea",
|
|
||||||
"select",
|
|
||||||
"option",
|
|
||||||
"form",
|
|
||||||
"[hidden]",
|
|
||||||
"[aria-hidden='true']",
|
|
||||||
".sr-only",
|
|
||||||
"[class*='sr-only']",
|
|
||||||
"[class*='file-tile']",
|
|
||||||
"form[data-type='unified-composer']",
|
|
||||||
".composer-btn",
|
|
||||||
"[data-composer-surface='true']",
|
|
||||||
"#thread-bottom-container",
|
|
||||||
"[data-testid*='action-button']",
|
|
||||||
].join(", ");
|
|
||||||
|
|
||||||
function normalizeText(value: string) {
|
|
||||||
return value.replace(/\s+/g, " ").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInline(value: string) {
|
|
||||||
return value
|
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
|
||||||
.replace(/\n[ \t]+/g, "\n")
|
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
|
||||||
.replace(/[ \t]{2,}/g, " ")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseBlankLines(value: string) {
|
|
||||||
return value
|
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeMarkdown(text: string) {
|
|
||||||
return text.replace(/([\\`[\]])/g, "\\$1");
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeTableCell(text: string) {
|
|
||||||
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function absoluteUrl(attr: string | null | undefined, fallback?: string) {
|
|
||||||
return attr || fallback || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNoiseElement(node: Node | null): boolean {
|
|
||||||
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
|
|
||||||
const el = node as Element;
|
|
||||||
const tag = el.tagName.toLowerCase();
|
|
||||||
if (["script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog"].includes(tag)) return true;
|
|
||||||
if (["button", "input", "textarea", "select", "option", "form"].includes(tag)) return true;
|
|
||||||
if (el.hasAttribute("hidden")) return true;
|
|
||||||
if ((el.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
|
|
||||||
if (el.matches(".sr-only, [class*='sr-only']")) return true;
|
|
||||||
if (el.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
|
|
||||||
if (el.matches("[data-testid*='action-button']")) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripNoise(root: Element): Element {
|
|
||||||
const clone = root.cloneNode(true) as Element;
|
|
||||||
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
|
|
||||||
return clone;
|
|
||||||
}
|
|
||||||
|
|
||||||
function candidateScore(node: Element) {
|
|
||||||
const text = normalizeText((node as HTMLElement).innerText || "");
|
|
||||||
if (!text) return -Infinity;
|
|
||||||
|
|
||||||
const headings = node.querySelectorAll("h1, h2, h3, h4, h5, h6").length;
|
|
||||||
const paragraphs = node.querySelectorAll("p").length;
|
|
||||||
const listItems = node.querySelectorAll("li").length;
|
|
||||||
const tables = node.querySelectorAll("table").length;
|
|
||||||
const codeBlocks = node.querySelectorAll("pre, code").length;
|
|
||||||
const images = node.querySelectorAll("img, figure").length;
|
|
||||||
const mainLike = node.matches("main, article, [role='main']") ? 1 : 0;
|
|
||||||
const proseBlocks = node.matches(".markdown, .prose, [data-message-author-role='assistant']") ? 1 : 0;
|
|
||||||
const buttons = node.querySelectorAll("button, input, textarea, select").length;
|
|
||||||
const forms = node.querySelectorAll("form").length;
|
|
||||||
const svgs = node.querySelectorAll("svg, canvas").length;
|
|
||||||
|
|
||||||
return text.length
|
|
||||||
+ (mainLike * 4000)
|
|
||||||
+ (proseBlocks * 5000)
|
|
||||||
+ (headings * 250)
|
|
||||||
+ (paragraphs * 60)
|
|
||||||
+ (listItems * 35)
|
|
||||||
+ (tables * 80)
|
|
||||||
+ (codeBlocks * 60)
|
|
||||||
+ (images * 25)
|
|
||||||
- (buttons * 120)
|
|
||||||
- (forms * 200)
|
|
||||||
- (svgs * 40);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickRoot() {
|
|
||||||
if (selector) {
|
|
||||||
const matched = document.querySelector(selector);
|
|
||||||
if (!matched) throw new Error(`No element: ${selector}`);
|
|
||||||
return matched;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = Array.from(document.querySelectorAll(
|
|
||||||
"main, article, [role='main'], section, .markdown, .prose, [data-message-author-role]"
|
|
||||||
))
|
|
||||||
.filter(node => normalizeText((node as HTMLElement).innerText || "").length > 0);
|
|
||||||
if (!candidates.length) return document.body;
|
|
||||||
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
|
|
||||||
return candidates[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function inlineText(node: Node): string {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
return escapeMarkdown(node.textContent || "");
|
|
||||||
}
|
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
|
||||||
if (isNoiseElement(node)) return "";
|
|
||||||
|
|
||||||
const el = node as HTMLElement;
|
|
||||||
const tag = el.tagName.toLowerCase();
|
|
||||||
if (tag === "br") return "\n";
|
|
||||||
if (tag === "img") {
|
|
||||||
const img = el as HTMLImageElement;
|
|
||||||
const src = absoluteUrl(img.getAttribute("src"), img.src);
|
|
||||||
if (!src) return "";
|
|
||||||
const alt = normalizeText(img.getAttribute("alt") || "");
|
|
||||||
return alt ? `` : ``;
|
|
||||||
}
|
|
||||||
if (tag === "a") {
|
|
||||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
|
||||||
const href = absoluteUrl(el.getAttribute("href"), (el as HTMLAnchorElement).href);
|
|
||||||
if (!href) return text;
|
|
||||||
return `[${text || href}](${href})`;
|
|
||||||
}
|
|
||||||
if (tag === "code") {
|
|
||||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
|
||||||
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
|
|
||||||
}
|
|
||||||
if (tag === "strong" || tag === "b") {
|
|
||||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
|
||||||
return text ? `**${text}**` : "";
|
|
||||||
}
|
|
||||||
if (tag === "em" || tag === "i") {
|
|
||||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
|
||||||
return text ? `*${text}*` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks: string[] = [];
|
|
||||||
for (const child of el.childNodes) {
|
|
||||||
const rendered = inlineText(child);
|
|
||||||
if (!rendered) continue;
|
|
||||||
chunks.push(rendered);
|
|
||||||
if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has((child as Element).tagName.toLowerCase())) {
|
|
||||||
chunks.push("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chunks.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function textBlock(node: Node): string {
|
|
||||||
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
|
|
||||||
}
|
|
||||||
|
|
||||||
function preserveNodeText(node: Node): string {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
return node.textContent || "";
|
|
||||||
}
|
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
|
||||||
|
|
||||||
const el = node as HTMLElement;
|
|
||||||
const tag = el.tagName.toLowerCase();
|
|
||||||
if (tag === "br") return "\n";
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
for (const child of el.childNodes) {
|
|
||||||
const rendered = preserveNodeText(child);
|
|
||||||
if (!rendered) continue;
|
|
||||||
parts.push(rendered);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (["div", "p", "li"].includes(tag)) {
|
|
||||||
return `${parts.join("")}\n`;
|
|
||||||
}
|
|
||||||
return parts.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function repairFlattenedDiagram(text: string): string {
|
|
||||||
if (text.includes("\n")) return text;
|
|
||||||
const markerCount = (text.match(/[│▼├└]/g) || []).length;
|
|
||||||
if (markerCount < 2) return text;
|
|
||||||
|
|
||||||
let repaired = text;
|
|
||||||
repaired = repaired.replace(/\s{2,}([│▼])/g, "\n $1");
|
|
||||||
repaired = repaired.replace(/([│▼])\s{2,}/g, "$1\n");
|
|
||||||
repaired = repaired.replace(/([│▼])(?=[^\s\n│▼├└])/g, "$1\n");
|
|
||||||
repaired = repaired.replace(/(?<=[^\s\n])([├└])/g, "\n$1");
|
|
||||||
repaired = repaired.replace(/([^\s\n])(\()/g, "$1\n$2");
|
|
||||||
return repaired
|
|
||||||
.split("\n")
|
|
||||||
.map(line => line.replace(/\s+$/, ""))
|
|
||||||
.filter(line => line.trim())
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertDashListsToBranches(lines: string[]): string[] {
|
|
||||||
const converted: string[] = [];
|
|
||||||
let index = 0;
|
|
||||||
while (index < lines.length) {
|
|
||||||
const match = lines[index].match(/^(\s*)-\s+(.*)$/);
|
|
||||||
if (!match) {
|
|
||||||
converted.push(lines[index]);
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const indent = match[1];
|
|
||||||
const items = [];
|
|
||||||
while (index < lines.length) {
|
|
||||||
const nextMatch = lines[index].match(new RegExp(`^${indent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\s+(.*)$`));
|
|
||||||
if (!nextMatch) break;
|
|
||||||
items.push(nextMatch[1]);
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach((item, itemIndex) => {
|
|
||||||
const branch = itemIndex === items.length - 1 ? "└" : "├";
|
|
||||||
converted.push(`${indent}${branch} ${item}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return converted;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCodeBlock(text: string): string {
|
|
||||||
let lines = text.replace(/\r\n?/g, "\n").split("\n").map(line => line.replace(/\s+$/, ""));
|
|
||||||
while (lines.length && !lines[0].trim()) lines.shift();
|
|
||||||
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
||||||
|
|
||||||
const flattened = repairFlattenedDiagram(lines.join("\n"));
|
|
||||||
lines = flattened ? flattened.split("\n") : [];
|
|
||||||
lines = lines.map(line => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if ((trimmed === "│" || trimmed === "▼") && !/^\s+[│▼]\s*$/.test(line)) {
|
|
||||||
return ` ${trimmed}`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
lines = convertDashListsToBranches(lines);
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableToMarkdown(table: Element) {
|
|
||||||
const rows = Array.from(table.querySelectorAll("tr"))
|
|
||||||
.map(row => Array.from(row.children)
|
|
||||||
.filter(cell => cell.tagName === "TD" || cell.tagName === "TH")
|
|
||||||
.map(cell => escapeTableCell(textBlock(cell)))
|
|
||||||
)
|
|
||||||
.filter(cells => cells.length > 0);
|
|
||||||
if (!rows.length) return "";
|
|
||||||
|
|
||||||
const widths = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
|
||||||
const normalizedRows = rows.map(row => {
|
|
||||||
const next = row.slice();
|
|
||||||
while (next.length < widths) next.push("");
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
let headers = normalizedRows[0];
|
|
||||||
let bodyRows = normalizedRows.slice(1);
|
|
||||||
const firstRowIsBlank = headers.every(cell => !cell.trim());
|
|
||||||
if (firstRowIsBlank && normalizedRows.length > 1) {
|
|
||||||
headers = normalizedRows[1];
|
|
||||||
bodyRows = normalizedRows.slice(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstRow = table.querySelector("tr");
|
|
||||||
const thead = table.querySelector("thead");
|
|
||||||
const firstRowHasTh = firstRow && Array.from(firstRow.children).some(cell => cell.tagName === "TH");
|
|
||||||
if (!(thead || firstRowHasTh || firstRowIsBlank)) {
|
|
||||||
headers = new Array(widths).fill("");
|
|
||||||
bodyRows = normalizedRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const separator = new Array(widths).fill("---");
|
|
||||||
const lines = [
|
|
||||||
`| ${headers.join(" | ")} |`,
|
|
||||||
`| ${separator.join(" | ")} |`,
|
|
||||||
];
|
|
||||||
for (const row of bodyRows) {
|
|
||||||
lines.push(`| ${row.join(" | ")} |`);
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function listToMarkdown(list: Element, depth = 0): string {
|
|
||||||
const ordered = list.tagName.toLowerCase() === "ol";
|
|
||||||
const items: string[] = [];
|
|
||||||
const children = Array.from(list.children).filter(child => child.tagName === "LI");
|
|
||||||
children.forEach((item, index) => {
|
|
||||||
const marker = ordered ? `${index + 1}. ` : "- ";
|
|
||||||
const indent = " ".repeat(depth);
|
|
||||||
const nested: string[] = [];
|
|
||||||
const content: string[] = [];
|
|
||||||
|
|
||||||
for (const child of item.childNodes) {
|
|
||||||
const childEl = child as Element;
|
|
||||||
if (child.nodeType === Node.ELEMENT_NODE && (childEl.tagName === "UL" || childEl.tagName === "OL")) {
|
|
||||||
nested.push(listToMarkdown(childEl, depth + 1));
|
|
||||||
} else {
|
|
||||||
content.push(inlineText(child));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = collapseBlankLines(normalizeInline(content.join("")));
|
|
||||||
if (line) {
|
|
||||||
const lineParts = line.split("\n");
|
|
||||||
items.push(`${indent}${marker}${lineParts[0]}`);
|
|
||||||
const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
|
|
||||||
lineParts.slice(1).forEach(part => items.push(`${continuationIndent}${part}`));
|
|
||||||
}
|
|
||||||
nested.filter(Boolean).forEach(block => items.push(block));
|
|
||||||
});
|
|
||||||
return items.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function blockToMarkdown(node: Node): string {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
return normalizeText(node.textContent || "");
|
|
||||||
}
|
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
|
||||||
if (isNoiseElement(node)) return "";
|
|
||||||
|
|
||||||
const el = node as HTMLElement;
|
|
||||||
const tag = el.tagName.toLowerCase();
|
|
||||||
if (tag === "table") return tableToMarkdown(el);
|
|
||||||
if (tag === "ul" || tag === "ol") return listToMarkdown(el);
|
|
||||||
if (el.matches(".cm-editor[data-is-code-block-view='true']")) {
|
|
||||||
const lines = Array.from(el.querySelectorAll(".cm-line")).map(line => {
|
|
||||||
const text = preserveNodeText(line);
|
|
||||||
return text === "\n" ? "" : text.replace(/\n$/, "");
|
|
||||||
});
|
|
||||||
const code = normalizeCodeBlock(lines.join("\n"));
|
|
||||||
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
|
||||||
}
|
|
||||||
if (tag === "pre") {
|
|
||||||
const code = normalizeCodeBlock(preserveNodeText(el));
|
|
||||||
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
|
||||||
}
|
|
||||||
if (tag === "blockquote") {
|
|
||||||
const content = collapseBlankLines(Array.from(el.childNodes).map(blockToMarkdown).join("\n\n"));
|
|
||||||
return content
|
|
||||||
.split("\n")
|
|
||||||
.map(line => line ? `> ${line}` : ">")
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
if (/^h[1-6]$/.test(tag)) {
|
|
||||||
const level = Number(tag.slice(1));
|
|
||||||
const text = textBlock(el);
|
|
||||||
return text ? `${"#".repeat(level)} ${text}` : "";
|
|
||||||
}
|
|
||||||
if (tag === "p" || tag === "figcaption") {
|
|
||||||
return textBlock(el);
|
|
||||||
}
|
|
||||||
if (tag === "hr") {
|
|
||||||
return "---";
|
|
||||||
}
|
|
||||||
if (tag === "img") {
|
|
||||||
return inlineText(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
const childBlocks = Array.from(el.childNodes)
|
|
||||||
.map(child => blockToMarkdown(child))
|
|
||||||
.filter(Boolean);
|
|
||||||
if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n"));
|
|
||||||
|
|
||||||
return textBlock(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = stripNoise(pickRoot());
|
|
||||||
const markdown = blockToMarkdown(root);
|
|
||||||
return collapseBlankLines(markdown);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
function repairFlattenedDiagram(text: string): string {
|
||||||
|
if (text.includes("\n")) return text;
|
||||||
|
const markerCount = (text.match(/[│▼├└]/g) || []).length;
|
||||||
|
if (markerCount < 2) return text;
|
||||||
|
|
||||||
|
let repaired = text;
|
||||||
|
repaired = repaired.replace(/\s{2,}([│▼])/g, "\n $1");
|
||||||
|
repaired = repaired.replace(/([│▼])\s{2,}/g, "$1\n");
|
||||||
|
repaired = repaired.replace(/([│▼])(?=[^\s\n│▼├└])/g, "$1\n");
|
||||||
|
repaired = repaired.replace(/(?<=[^\s\n])([├└])/g, "\n$1");
|
||||||
|
repaired = repaired.replace(/([^\s\n])(\()/g, "$1\n$2");
|
||||||
|
return repaired
|
||||||
|
.split("\n")
|
||||||
|
.map(line => line.replace(/\s+$/, ""))
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDashListsToBranches(lines: string[]): string[] {
|
||||||
|
const converted: string[] = [];
|
||||||
|
let index = 0;
|
||||||
|
while (index < lines.length) {
|
||||||
|
const match = lines[index].match(/^(\s*)-\s+(.*)$/);
|
||||||
|
if (!match) {
|
||||||
|
converted.push(lines[index]);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indent = match[1];
|
||||||
|
const items = [];
|
||||||
|
while (index < lines.length) {
|
||||||
|
const nextMatch = lines[index].match(new RegExp(`^${indent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\s+(.*)$`));
|
||||||
|
if (!nextMatch) break;
|
||||||
|
items.push(nextMatch[1]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item, itemIndex) => {
|
||||||
|
const branch = itemIndex === items.length - 1 ? "└" : "├";
|
||||||
|
converted.push(`${indent}${branch} ${item}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCodeBlock(text: string): string {
|
||||||
|
let lines = text.replace(/\r\n?/g, "\n").split("\n").map(line => line.replace(/\s+$/, ""));
|
||||||
|
while (lines.length && !lines[0].trim()) lines.shift();
|
||||||
|
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
||||||
|
|
||||||
|
const flattened = repairFlattenedDiagram(lines.join("\n"));
|
||||||
|
lines = flattened ? flattened.split("\n") : [];
|
||||||
|
lines = lines.map(line => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if ((trimmed === "│" || trimmed === "▼") && !/^\s+[│▼]\s*$/.test(line)) {
|
||||||
|
return ` ${trimmed}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
lines = convertDashListsToBranches(lines);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ContentArgs } from '../../types';
|
||||||
|
import { pickMarkdownRoot } from './root';
|
||||||
|
import { renderMarkdown } from './renderer';
|
||||||
|
import { stripNoise } from './utils';
|
||||||
|
|
||||||
|
export function extractMarkdown({ selector }: ContentArgs) {
|
||||||
|
const root = stripNoise(pickMarkdownRoot(selector));
|
||||||
|
return renderMarkdown(root);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { normalizeCodeBlock } from './code';
|
||||||
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
BLOCK_TAGS,
|
||||||
|
collapseBlankLines,
|
||||||
|
escapeMarkdown,
|
||||||
|
escapeTableCell,
|
||||||
|
isNoiseElement,
|
||||||
|
normalizeInline,
|
||||||
|
normalizeText,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
function inlineText(node: Node): string {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return escapeMarkdown(node.textContent || "");
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||||
|
if (isNoiseElement(node)) return "";
|
||||||
|
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (tag === "br") return "\n";
|
||||||
|
if (tag === "img") {
|
||||||
|
const img = el as HTMLImageElement;
|
||||||
|
const src = absoluteUrl(img.getAttribute("src"), img.src);
|
||||||
|
if (!src) return "";
|
||||||
|
const alt = normalizeText(img.getAttribute("alt") || "");
|
||||||
|
return alt ? `` : ``;
|
||||||
|
}
|
||||||
|
if (tag === "a") {
|
||||||
|
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||||
|
const href = absoluteUrl(el.getAttribute("href"), (el as HTMLAnchorElement).href);
|
||||||
|
if (!href) return text;
|
||||||
|
return `[${text || href}](${href})`;
|
||||||
|
}
|
||||||
|
if (tag === "code") {
|
||||||
|
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||||
|
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
|
||||||
|
}
|
||||||
|
if (tag === "strong" || tag === "b") {
|
||||||
|
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||||
|
return text ? `**${text}**` : "";
|
||||||
|
}
|
||||||
|
if (tag === "em" || tag === "i") {
|
||||||
|
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||||
|
return text ? `*${text}*` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (const child of el.childNodes) {
|
||||||
|
const rendered = inlineText(child);
|
||||||
|
if (!rendered) continue;
|
||||||
|
chunks.push(rendered);
|
||||||
|
if (child.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has((child as Element).tagName.toLowerCase())) {
|
||||||
|
chunks.push("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunks.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function textBlock(node: Node): string {
|
||||||
|
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveNodeText(node: Node): string {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return node.textContent || "";
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||||
|
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (tag === "br") return "\n";
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const child of el.childNodes) {
|
||||||
|
const rendered = preserveNodeText(child);
|
||||||
|
if (!rendered) continue;
|
||||||
|
parts.push(rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["div", "p", "li"].includes(tag)) {
|
||||||
|
return `${parts.join("")}\n`;
|
||||||
|
}
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableToMarkdown(table: Element) {
|
||||||
|
const rows = Array.from(table.querySelectorAll("tr"))
|
||||||
|
.map(row => Array.from(row.children)
|
||||||
|
.filter(cell => cell.tagName === "TD" || cell.tagName === "TH")
|
||||||
|
.map(cell => escapeTableCell(textBlock(cell)))
|
||||||
|
)
|
||||||
|
.filter(cells => cells.length > 0);
|
||||||
|
if (!rows.length) return "";
|
||||||
|
|
||||||
|
const widths = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
||||||
|
const normalizedRows = rows.map(row => {
|
||||||
|
const next = row.slice();
|
||||||
|
while (next.length < widths) next.push("");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
let headers = normalizedRows[0];
|
||||||
|
let bodyRows = normalizedRows.slice(1);
|
||||||
|
const firstRowIsBlank = headers.every(cell => !cell.trim());
|
||||||
|
if (firstRowIsBlank && normalizedRows.length > 1) {
|
||||||
|
headers = normalizedRows[1];
|
||||||
|
bodyRows = normalizedRows.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRow = table.querySelector("tr");
|
||||||
|
const thead = table.querySelector("thead");
|
||||||
|
const firstRowHasTh = firstRow && Array.from(firstRow.children).some(cell => cell.tagName === "TH");
|
||||||
|
if (!(thead || firstRowHasTh || firstRowIsBlank)) {
|
||||||
|
headers = new Array(widths).fill("");
|
||||||
|
bodyRows = normalizedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = new Array(widths).fill("---");
|
||||||
|
const lines = [
|
||||||
|
`| ${headers.join(" | ")} |`,
|
||||||
|
`| ${separator.join(" | ")} |`,
|
||||||
|
];
|
||||||
|
for (const row of bodyRows) {
|
||||||
|
lines.push(`| ${row.join(" | ")} |`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function listToMarkdown(list: Element, depth = 0): string {
|
||||||
|
const ordered = list.tagName.toLowerCase() === "ol";
|
||||||
|
const items: string[] = [];
|
||||||
|
const children = Array.from(list.children).filter(child => child.tagName === "LI");
|
||||||
|
children.forEach((item, index) => {
|
||||||
|
const marker = ordered ? `${index + 1}. ` : "- ";
|
||||||
|
const indent = " ".repeat(depth);
|
||||||
|
const nested: string[] = [];
|
||||||
|
const content: string[] = [];
|
||||||
|
|
||||||
|
for (const child of item.childNodes) {
|
||||||
|
const childEl = child as Element;
|
||||||
|
if (child.nodeType === Node.ELEMENT_NODE && (childEl.tagName === "UL" || childEl.tagName === "OL")) {
|
||||||
|
nested.push(listToMarkdown(childEl, depth + 1));
|
||||||
|
} else {
|
||||||
|
content.push(inlineText(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = collapseBlankLines(normalizeInline(content.join("")));
|
||||||
|
if (line) {
|
||||||
|
const lineParts = line.split("\n");
|
||||||
|
items.push(`${indent}${marker}${lineParts[0]}`);
|
||||||
|
const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
|
||||||
|
lineParts.slice(1).forEach(part => items.push(`${continuationIndent}${part}`));
|
||||||
|
}
|
||||||
|
nested.filter(Boolean).forEach(block => items.push(block));
|
||||||
|
});
|
||||||
|
return items.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockToMarkdown(node: Node): string {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return normalizeText(node.textContent || "");
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||||
|
if (isNoiseElement(node)) return "";
|
||||||
|
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (tag === "table") return tableToMarkdown(el);
|
||||||
|
if (tag === "ul" || tag === "ol") return listToMarkdown(el);
|
||||||
|
if (el.matches(".cm-editor[data-is-code-block-view='true']")) {
|
||||||
|
const lines = Array.from(el.querySelectorAll(".cm-line")).map(line => {
|
||||||
|
const text = preserveNodeText(line);
|
||||||
|
return text === "\n" ? "" : text.replace(/\n$/, "");
|
||||||
|
});
|
||||||
|
const code = normalizeCodeBlock(lines.join("\n"));
|
||||||
|
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
||||||
|
}
|
||||||
|
if (tag === "pre") {
|
||||||
|
const code = normalizeCodeBlock(preserveNodeText(el));
|
||||||
|
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
||||||
|
}
|
||||||
|
if (tag === "blockquote") {
|
||||||
|
const content = collapseBlankLines(Array.from(el.childNodes).map(blockToMarkdown).join("\n\n"));
|
||||||
|
return content
|
||||||
|
.split("\n")
|
||||||
|
.map(line => line ? `> ${line}` : ">")
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
if (/^h[1-6]$/.test(tag)) {
|
||||||
|
const level = Number(tag.slice(1));
|
||||||
|
const text = textBlock(el);
|
||||||
|
return text ? `${"#".repeat(level)} ${text}` : "";
|
||||||
|
}
|
||||||
|
if (tag === "p" || tag === "figcaption") {
|
||||||
|
return textBlock(el);
|
||||||
|
}
|
||||||
|
if (tag === "hr") {
|
||||||
|
return "---";
|
||||||
|
}
|
||||||
|
if (tag === "img") {
|
||||||
|
return inlineText(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
const childBlocks = Array.from(el.childNodes)
|
||||||
|
.map(child => blockToMarkdown(child))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n"));
|
||||||
|
|
||||||
|
return textBlock(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdown(root: Element): string {
|
||||||
|
return collapseBlankLines(blockToMarkdown(root));
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { normalizeText } from './utils';
|
||||||
|
|
||||||
|
function candidateScore(node: Element) {
|
||||||
|
const text = normalizeText((node as HTMLElement).innerText || "");
|
||||||
|
if (!text) return -Infinity;
|
||||||
|
|
||||||
|
const headings = node.querySelectorAll("h1, h2, h3, h4, h5, h6").length;
|
||||||
|
const paragraphs = node.querySelectorAll("p").length;
|
||||||
|
const listItems = node.querySelectorAll("li").length;
|
||||||
|
const tables = node.querySelectorAll("table").length;
|
||||||
|
const codeBlocks = node.querySelectorAll("pre, code").length;
|
||||||
|
const images = node.querySelectorAll("img, figure").length;
|
||||||
|
const mainLike = node.matches("main, article, [role='main']") ? 1 : 0;
|
||||||
|
const proseBlocks = node.matches(".markdown, .prose, [data-message-author-role='assistant']") ? 1 : 0;
|
||||||
|
const buttons = node.querySelectorAll("button, input, textarea, select").length;
|
||||||
|
const forms = node.querySelectorAll("form").length;
|
||||||
|
const svgs = node.querySelectorAll("svg, canvas").length;
|
||||||
|
|
||||||
|
return text.length
|
||||||
|
+ (mainLike * 4000)
|
||||||
|
+ (proseBlocks * 5000)
|
||||||
|
+ (headings * 250)
|
||||||
|
+ (paragraphs * 60)
|
||||||
|
+ (listItems * 35)
|
||||||
|
+ (tables * 80)
|
||||||
|
+ (codeBlocks * 60)
|
||||||
|
+ (images * 25)
|
||||||
|
- (buttons * 120)
|
||||||
|
- (forms * 200)
|
||||||
|
- (svgs * 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickMarkdownRoot(selector?: string) {
|
||||||
|
if (selector) {
|
||||||
|
const matched = document.querySelector(selector);
|
||||||
|
if (!matched) throw new Error(`No element: ${selector}`);
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = Array.from(document.querySelectorAll(
|
||||||
|
"main, article, [role='main'], section, .markdown, .prose, [data-message-author-role]"
|
||||||
|
))
|
||||||
|
.filter(node => normalizeText((node as HTMLElement).innerText || "").length > 0);
|
||||||
|
if (!candidates.length) return document.body;
|
||||||
|
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
export const BLOCK_TAGS = new Set([
|
||||||
|
"article", "aside", "blockquote", "body", "div", "dl", "fieldset", "figcaption",
|
||||||
|
"figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr",
|
||||||
|
"li", "main", "nav", "ol", "p", "pre", "section", "table", "tbody", "td", "tfoot",
|
||||||
|
"th", "thead", "tr", "ul"
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const NOISE_SELECTOR = [
|
||||||
|
"script",
|
||||||
|
"style",
|
||||||
|
"noscript",
|
||||||
|
"template",
|
||||||
|
"svg",
|
||||||
|
"canvas",
|
||||||
|
"iframe",
|
||||||
|
"dialog",
|
||||||
|
"button",
|
||||||
|
"input",
|
||||||
|
"textarea",
|
||||||
|
"select",
|
||||||
|
"option",
|
||||||
|
"form",
|
||||||
|
"[hidden]",
|
||||||
|
"[aria-hidden='true']",
|
||||||
|
".sr-only",
|
||||||
|
"[class*='sr-only']",
|
||||||
|
"[class*='file-tile']",
|
||||||
|
"form[data-type='unified-composer']",
|
||||||
|
".composer-btn",
|
||||||
|
"[data-composer-surface='true']",
|
||||||
|
"#thread-bottom-container",
|
||||||
|
"[data-testid*='action-button']",
|
||||||
|
].join(", ");
|
||||||
|
|
||||||
|
export function normalizeText(value: string) {
|
||||||
|
return value.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeInline(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/\n[ \t]+/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collapseBlankLines(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeMarkdown(text: string) {
|
||||||
|
return text.replace(/([\\`[\]])/g, "\\$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeTableCell(text: string) {
|
||||||
|
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absoluteUrl(attr: string | null | undefined, fallback?: string) {
|
||||||
|
return attr || fallback || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNoiseElement(node: Node | null): boolean {
|
||||||
|
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||||
|
const el = node as Element;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (["script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog"].includes(tag)) return true;
|
||||||
|
if (["button", "input", "textarea", "select", "option", "form"].includes(tag)) return true;
|
||||||
|
if (el.hasAttribute("hidden")) return true;
|
||||||
|
if ((el.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
|
||||||
|
if (el.matches(".sr-only, [class*='sr-only']")) return true;
|
||||||
|
if (el.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
|
||||||
|
if (el.matches("[data-testid*='action-button']")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripNoise(root: Element): Element {
|
||||||
|
const clone = root.cloneNode(true) as Element;
|
||||||
|
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import type { TabGroupColor } from '../types';
|
||||||
|
import { webExtApi as api } from '../browser-api';
|
||||||
// Tab-group resolution and normalization helpers.
|
// Tab-group resolution and normalization helpers.
|
||||||
|
import { queryTabGroups } from './tab-groups';
|
||||||
|
|
||||||
export async function resolveGroupId(nameOrId: string | number): Promise<number> {
|
export async function resolveGroupId(nameOrId: string | number): Promise<number> {
|
||||||
const asInt = parseInt(String(nameOrId));
|
const asInt = parseInt(String(nameOrId));
|
||||||
if (!isNaN(asInt)) return asInt;
|
if (!isNaN(asInt)) return asInt;
|
||||||
const groups = await chrome.tabGroups.query({});
|
const groups = await queryTabGroups({});
|
||||||
const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase());
|
const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase());
|
||||||
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
|
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
|
||||||
return match.id;
|
return match.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeGroupColor(color: string | undefined): chrome.tabGroups.Color {
|
export function normalizeGroupColor(color: string | undefined): TabGroupColor {
|
||||||
const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
|
const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
|
||||||
return (allowed.has(color as string) ? color : "grey") as chrome.tabGroups.Color;
|
return (allowed.has(color as string) ? color : "grey") as TabGroupColor;
|
||||||
}
|
}
|
||||||
|
|||||||