Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3142858f30 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "agent-browser",
|
||||
"description": "Browser automation for AI agents",
|
||||
"description": "Headless browser automation for AI agents",
|
||||
"owner": {
|
||||
"name": "Vercel",
|
||||
"email": "support@vercel.com"
|
||||
|
||||
+98
-151
@@ -5,52 +5,53 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
version-sync:
|
||||
name: Version Sync Check
|
||||
typescript:
|
||||
name: TypeScript (Node ${{ matrix.node-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check version sync
|
||||
run: node scripts/check-version-sync.js
|
||||
|
||||
rust:
|
||||
name: Rust
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
version: 9
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
workspaces: cli
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --manifest-path cli/Cargo.toml -- --check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Clippy check
|
||||
run: cargo clippy --manifest-path cli/Cargo.toml -- -D warnings
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
rust-cross:
|
||||
rust:
|
||||
name: Rust (${{ matrix.os }} - ${{ matrix.target }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- os: macos-latest
|
||||
@@ -67,61 +68,72 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
workspaces: cli
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.target }}-
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
native-e2e:
|
||||
name: Native E2E Tests
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: rust
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Install Chrome
|
||||
run: |
|
||||
cargo run --manifest-path cli/Cargo.toml -- install --with-deps
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e -- --ignored --test-threads=1
|
||||
run: cargo test --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
windows-integration:
|
||||
name: Windows Integration Test
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: windows-latest
|
||||
needs: rust-cross
|
||||
needs: rust
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
workspaces: cli
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: windows-cargo-x86_64-pc-windows-msvc-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
windows-cargo-x86_64-pc-windows-msvc-
|
||||
|
||||
- name: Build Rust CLI
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build TypeScript
|
||||
run: pnpm build
|
||||
|
||||
- name: Copy CLI binary to bin directory
|
||||
run: |
|
||||
Copy-Item cli/target/x86_64-pc-windows-msvc/release/agent-browser.exe bin/agent-browser-win32-x64.exe
|
||||
@@ -129,113 +141,48 @@ jobs:
|
||||
- name: Test agent-browser install command
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
bin/agent-browser-win32-x64.exe install
|
||||
if ($LASTEXITCODE -eq 0) { exit 0 }
|
||||
Write-Host "Attempt $i failed, retrying in 10 seconds..."
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
exit 1
|
||||
bin/agent-browser-win32-x64.exe install
|
||||
shell: pwsh
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Test daemon lifecycle (open, snapshot, close)
|
||||
- name: Verify Chromium was installed
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
Write-Host "--- Opening page ---"
|
||||
bin/agent-browser-win32-x64.exe open https://example.com
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "open failed"; exit 1 }
|
||||
Write-Host "--- Taking snapshot ---"
|
||||
$snapshot = bin/agent-browser-win32-x64.exe snapshot
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "snapshot failed"; exit 1 }
|
||||
Write-Host $snapshot
|
||||
Write-Host "--- Closing browser ---"
|
||||
bin/agent-browser-win32-x64.exe close
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "close failed"; exit 1 }
|
||||
Write-Host "--- Windows daemon lifecycle test passed ---"
|
||||
$playwrightPath = "$env:LOCALAPPDATA\ms-playwright"
|
||||
if (Test-Path $playwrightPath) {
|
||||
Write-Host "Playwright browsers installed at: $playwrightPath"
|
||||
Get-ChildItem $playwrightPath -Recurse -Depth 2 | Select-Object -First 20
|
||||
} else {
|
||||
Write-Error "Playwright browsers not found!"
|
||||
exit 1
|
||||
}
|
||||
shell: pwsh
|
||||
timeout-minutes: 5
|
||||
|
||||
global-install:
|
||||
name: Global Install (${{ matrix.os }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: rust-cross
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-x64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: agent-browser-darwin-arm64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: agent-browser-win32-x64.exe
|
||||
serverless-chromium:
|
||||
name: Serverless Chromium (@sparticuz/chromium)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
- name: Install @sparticuz/chromium
|
||||
run: pnpm add -D @sparticuz/chromium
|
||||
|
||||
- name: Build Rust CLI
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
- name: Build TypeScript
|
||||
run: pnpm build
|
||||
|
||||
- name: Copy CLI binary to bin directory (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: cp cli/target/${{ matrix.target }}/release/agent-browser bin/${{ matrix.binary }}
|
||||
|
||||
- name: Copy CLI binary to bin directory (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: Copy-Item cli/target/${{ matrix.target }}/release/agent-browser.exe bin/${{ matrix.binary }}
|
||||
|
||||
- name: Test npm global install
|
||||
run: |
|
||||
npm pack
|
||||
npm install -g agent-browser-*.tgz
|
||||
agent-browser --version
|
||||
shell: bash
|
||||
|
||||
- name: Verify symlink points to native binary (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
SYMLINK=$(npm prefix -g)/bin/agent-browser
|
||||
TARGET=$(readlink "$SYMLINK")
|
||||
echo "Symlink: $SYMLINK"
|
||||
echo "Target: $TARGET"
|
||||
if [[ "$TARGET" != *"${{ matrix.binary }}"* ]]; then
|
||||
echo "ERROR: Symlink should point to native binary, not JS wrapper"
|
||||
exit 1
|
||||
fi
|
||||
echo "Symlink correctly points to native binary"
|
||||
shell: bash
|
||||
|
||||
- name: Verify shim points to native binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
$shimPath = "$(npm prefix -g)\agent-browser.cmd"
|
||||
$content = Get-Content $shimPath -Raw
|
||||
echo "Shim path: $shimPath"
|
||||
echo "Shim content:"
|
||||
echo $content
|
||||
if ($content -notmatch "agent-browser-win32-x64\.exe") {
|
||||
echo "ERROR: Shim should point to native .exe, not JS wrapper"
|
||||
exit 1
|
||||
}
|
||||
echo "Shim correctly points to native binary"
|
||||
shell: pwsh
|
||||
- name: Run serverless integration test
|
||||
run: pnpm exec vitest run test/serverless.test.ts
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
check-release:
|
||||
name: Check for new version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_release: ${{ steps.check.outputs.should_release }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compare package.json version to npm
|
||||
id: check
|
||||
run: |
|
||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Local version: $LOCAL_VERSION"
|
||||
|
||||
NPM_VERSION=$(npm view agent-browser version 2>/dev/null || echo "0.0.0")
|
||||
echo "npm version: $NPM_VERSION"
|
||||
|
||||
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
|
||||
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
|
||||
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version unchanged, skipping release"
|
||||
echo "should_release=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: check-release
|
||||
if: needs.check-release.outputs.should_release == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Linux x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-x64
|
||||
use_zigbuild: true
|
||||
- name: Linux ARM64
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-arm64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-x64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl ARM64
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-arm64
|
||||
use_zigbuild: true
|
||||
- name: Windows x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
binary: agent-browser-win32-x64.exe
|
||||
use_zigbuild: false
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
binary: agent-browser-darwin-x64
|
||||
use_zigbuild: false
|
||||
- name: macOS ARM64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: agent-browser-darwin-arm64
|
||||
use_zigbuild: false
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Sync version
|
||||
run: pnpm run version:sync
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross-compilation tools (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu mingw-w64
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
run: |
|
||||
pip3 install ziglang
|
||||
cargo install cargo-zigbuild
|
||||
|
||||
- name: Configure Rust linkers
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
mkdir -p ~/.cargo
|
||||
cat >> ~/.cargo/config.toml << 'EOF'
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
EOF
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Build with zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
run: cargo zigbuild --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Build with cargo
|
||||
if: '!matrix.use_zigbuild'
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Copy binary
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
|
||||
cp cli/target/${{ matrix.target }}/release/agent-browser.exe artifacts/${{ matrix.binary }}
|
||||
else
|
||||
cp cli/target/${{ matrix.target }}/release/agent-browser artifacts/${{ matrix.binary }}
|
||||
chmod +x artifacts/${{ matrix.binary }}
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.binary }}
|
||||
path: artifacts/${{ matrix.binary }}
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: [check-release, build-binaries]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download all binary artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/
|
||||
|
||||
- name: Move binaries to bin directory
|
||||
run: |
|
||||
mkdir -p bin
|
||||
find artifacts -type f -name 'agent-browser-*' -exec mv {} bin/ \;
|
||||
rm -rf artifacts
|
||||
chmod +x bin/agent-browser-* 2>/dev/null || true
|
||||
echo "Binaries in bin/:"
|
||||
ls -la bin/
|
||||
|
||||
- name: Verify all binaries exist
|
||||
run: |
|
||||
EXPECTED_BINARIES=(
|
||||
"agent-browser-linux-x64"
|
||||
"agent-browser-linux-arm64"
|
||||
"agent-browser-linux-musl-x64"
|
||||
"agent-browser-linux-musl-arm64"
|
||||
"agent-browser-win32-x64.exe"
|
||||
"agent-browser-darwin-x64"
|
||||
"agent-browser-darwin-arm64"
|
||||
)
|
||||
MIN_SIZE=100000
|
||||
ERRORS=0
|
||||
for binary in "${EXPECTED_BINARIES[@]}"; do
|
||||
if [ ! -f "bin/$binary" ]; then
|
||||
echo "ERROR: Missing bin/$binary"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
SIZE=$(stat -c%s "bin/$binary" 2>/dev/null || stat -f%z "bin/$binary")
|
||||
if [ "$SIZE" -lt "$MIN_SIZE" ]; then
|
||||
echo "ERROR: bin/$binary is too small ($SIZE bytes, expected >= $MIN_SIZE)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "OK: bin/$binary ($SIZE bytes)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "Error: $ERRORS binary issues found"
|
||||
exit 1
|
||||
fi
|
||||
echo "All 7 platform binaries present and valid"
|
||||
|
||||
- name: Publish to npm
|
||||
run: pnpm publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
|
||||
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
needs: [check-release, publish]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/
|
||||
|
||||
- name: Move binaries to bin directory
|
||||
run: |
|
||||
mkdir -p bin
|
||||
find artifacts -type f -name 'agent-browser-*' -exec mv {} bin/ \;
|
||||
rm -rf artifacts
|
||||
chmod +x bin/agent-browser-* 2>/dev/null || true
|
||||
ls -la bin/
|
||||
|
||||
- name: Verify binaries exist
|
||||
run: |
|
||||
BINARY_COUNT=$(ls bin/agent-browser-* 2>/dev/null | wc -l)
|
||||
if [ "$BINARY_COUNT" -lt 7 ]; then
|
||||
echo "Error: Expected 7 binaries, found $BINARY_COUNT"
|
||||
ls -la bin/
|
||||
exit 1
|
||||
fi
|
||||
echo "Found $BINARY_COUNT binaries"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build dashboard
|
||||
run: pnpm --filter dashboard build
|
||||
|
||||
- name: Create dashboard.zip
|
||||
run: cd packages/dashboard/out && zip -r ../../../dashboard.zip .
|
||||
|
||||
- name: Extract changelog entry
|
||||
run: |
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
awk '/<!-- release:start -->/{found=1; next} /<!-- release:end -->/{found=0} found{print}' CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
LINES=$(wc -l < /tmp/release-notes.md | tr -d ' ')
|
||||
if [ "$LINES" -lt 2 ]; then
|
||||
echo "Error: No release notes found between <!-- release:start --> and <!-- release:end --> markers in CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracted release notes for $VERSION ($LINES lines)"
|
||||
|
||||
- name: Create GitHub Release
|
||||
run: |
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
TAG="v$VERSION"
|
||||
|
||||
if gh release view "$TAG" &>/dev/null; then
|
||||
echo "Release $TAG already exists, uploading assets..."
|
||||
gh release upload "$TAG" bin/agent-browser-* dashboard.zip --clobber
|
||||
else
|
||||
echo "Creating release $TAG..."
|
||||
gh release create "$TAG" \
|
||||
--title "$TAG" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
bin/agent-browser-* dashboard.zip
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
-16
@@ -6,7 +6,6 @@ dist/
|
||||
|
||||
# Native binaries (keep the launcher scripts)
|
||||
bin/agent-browser-*
|
||||
bin/.install-method
|
||||
!bin/agent-browser
|
||||
!bin/agent-browser.cmd
|
||||
|
||||
@@ -28,15 +27,10 @@ npm-debug.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
# Test artifacts
|
||||
*.png
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.webm
|
||||
test/e2e/.dogfood-output/
|
||||
|
||||
# Package manager
|
||||
package-lock.json
|
||||
@@ -46,9 +40,6 @@ yarn.lock
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Windows debug instance config
|
||||
scripts/windows-debug/.instance
|
||||
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
|
||||
@@ -57,10 +48,3 @@ docs/node_modules/
|
||||
docs/.next/
|
||||
docs/out/
|
||||
docs/package-lock.json
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
# next
|
||||
.next/
|
||||
out/
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
node scripts/sync-version.js
|
||||
git add cli/Cargo.toml cli/Cargo.lock
|
||||
pnpm lint-staged
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
if [ "${SKIP_CLAWHUB_SYNC:-0}" = "1" ]; then
|
||||
echo "Skipping ClawHub sync (SKIP_CLAWHUB_SYNC=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pnpm run clawhub:sync || {
|
||||
echo "ClawHub sync failed. Push continues. Run 'pnpm run clawhub:sync' manually after fixing login/network."
|
||||
}
|
||||
@@ -2,188 +2,10 @@
|
||||
|
||||
Instructions for AI coding agents working with this codebase.
|
||||
|
||||
## Package Manager
|
||||
|
||||
This project uses **pnpm**. Always use `pnpm` instead of `npm` or `yarn` for installing dependencies, running scripts, etc. (e.g., `pnpm install`, `pnpm run build`).
|
||||
|
||||
## Code Style
|
||||
|
||||
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable.
|
||||
- In documentation and markdown, never use double hyphens (`--`) as a dash. Use an emdash (—) sparingly when needed. Prefer rewriting the sentence to avoid dashes entirely.
|
||||
- CLI colored output uses `cli/src/color.rs`. This module respects the `NO_COLOR` environment variable. Never use hardcoded ANSI color codes.
|
||||
- CLI flags must always use kebab-case (e.g., `--auto-connect`, `--allow-file-access`). Never use camelCase for flags (e.g., `--autoConnect` is wrong).
|
||||
|
||||
## Documentation
|
||||
|
||||
When adding or changing user-facing features (new flags, commands, behaviors, environment variables, etc.), update **all** of the following:
|
||||
|
||||
1. `cli/src/output.rs` — `--help` output (flags list, examples, environment variables)
|
||||
2. `README.md` — Options table, relevant feature sections, examples
|
||||
3. `skills/agent-browser/SKILL.md` — so AI agents know about the feature
|
||||
4. `docs/src/app/` — the Next.js docs site (MDX pages)
|
||||
5. Inline doc comments in the relevant source files
|
||||
|
||||
This applies to changes that either human users or AI agents would need to know about. Do not skip any of these locations.
|
||||
|
||||
In the `docs/src/app/` MDX files, always use HTML `<table>` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site.
|
||||
|
||||
## Dashboard (packages/dashboard)
|
||||
|
||||
- Never use native browser dialogs (`alert`, `confirm`, `prompt`). Use shadcn/ui components (`Dialog`, `AlertDialog`, etc.) instead.
|
||||
- Use param-case (kebab-case) for all file and folder names (e.g., `session-tree.tsx`, not `SessionTree.tsx`). The `ui/` directory follows shadcn conventions which already uses param-case.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are manual, single-PR affairs. There is no changesets automation. The maintainer controls the changelog voice and format.
|
||||
|
||||
To prepare a release:
|
||||
|
||||
1. Create a branch (e.g. `prepare-v0.24.0`)
|
||||
2. Bump `version` in `package.json`
|
||||
3. Run `pnpm version:sync` to update `cli/Cargo.toml`, `cli/Cargo.lock`, and `packages/dashboard/package.json`
|
||||
4. Write the changelog entry in `CHANGELOG.md` at the top, under a new `## <version>` heading, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
|
||||
5. Add a matching entry to `docs/src/app/changelog/page.mdx` at the top (below the `# Changelog` heading)
|
||||
6. Open a PR and merge to `main`
|
||||
|
||||
When the PR merges, CI compares `package.json` version to what's on npm. If it differs, it builds all 7 platform binaries, publishes to npm, and creates the GitHub release automatically. The GitHub release body is extracted from the content between the `<!-- release:start -->` and `<!-- release:end -->` markers in `CHANGELOG.md`.
|
||||
|
||||
### Writing the changelog
|
||||
|
||||
Review the git log since the last release and write the entry in `CHANGELOG.md`. Follow the existing format and voice. Group changes under `### New Features`, `### Bug Fixes`, `### Improvements`, etc. Bold the feature/fix name, then describe it concisely. Reference PR numbers in parentheses.
|
||||
|
||||
Wrap the release notes (everything between the `## <version>` heading and the previous version) in markers so CI can extract them for the GitHub release:
|
||||
|
||||
```markdown
|
||||
## 0.24.0
|
||||
|
||||
<!-- release:start -->
|
||||
### New Features
|
||||
|
||||
- **Foo command** - Added `foo` command for bar (#1234)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **baz** not working when qux is enabled (#1235)
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @somecontributor
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.23.3
|
||||
```
|
||||
|
||||
Include a `### Contributors` section listing the GitHub usernames (with `@` prefix) of everyone who contributed to the release. Check the git log between the previous tag and HEAD to find them.
|
||||
|
||||
Do not prefix entries with commit hashes. Do not use the changesets `### Patch Changes` / `### Minor Changes` headings. Use descriptive section names instead.
|
||||
|
||||
### Docs changelog
|
||||
|
||||
The docs changelog at `docs/src/app/changelog/page.mdx` mirrors `CHANGELOG.md` but uses a slightly different format. Each entry uses:
|
||||
|
||||
- A `v` prefix on the version (e.g. `## v0.24.0`)
|
||||
- A date line with the full date: `<p className="text-[#888] text-sm">March 30, 2026</p>`
|
||||
- A `---` separator between entries
|
||||
|
||||
Match the existing style in that file.
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Rust codebase. The browser automation daemon lives in `cli/src/native/` (daemon, actions, browser, CDP client, snapshot, state). The `--engine` flag selects Chrome vs Lightpanda. The `install` command downloads Chrome from Chrome for Testing directly.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
cd cli && cargo test
|
||||
```
|
||||
|
||||
Runs all unit tests (~320 tests). These are fast and don't require Chrome.
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
```bash
|
||||
cd cli && cargo test e2e -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
Runs 18 e2e tests that launch real headless Chrome instances and exercise the full native daemon command pipeline. Requirements:
|
||||
|
||||
- Chrome must be installed
|
||||
- Must run serially (`--test-threads=1`) to avoid Chrome instance contention
|
||||
- Tests are `#[ignore]`'d so they don't run during normal `cargo test`
|
||||
|
||||
The e2e tests live in `cli/src/native/e2e_tests.rs` and cover: launch/close, navigation, snapshots, screenshots, form interaction, cookies, storage, tabs, element queries, viewport/emulation, domain filtering, diff, state management, error handling, and Phase 8 commands.
|
||||
|
||||
### Linting and Formatting
|
||||
|
||||
```bash
|
||||
cd cli && cargo fmt -- --check # Check formatting
|
||||
cd cli && cargo clippy # Lint
|
||||
```
|
||||
|
||||
## Windows Debugging
|
||||
|
||||
A remote Windows Server 2022 EC2 instance is available for debugging Windows-specific issues. It uses AWS Systems Manager (SSM) with no SSH or open ports. Commands run via `aws ssm send-command` and return stdout/stderr.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The instance must be provisioned first (one-time, by a human):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/provision.sh
|
||||
```
|
||||
|
||||
Requires: AWS CLI v2 configured with `ec2:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `ssm:SendCommand`, `ssm:GetCommandInvocation` permissions and a default VPC.
|
||||
|
||||
### Usage
|
||||
|
||||
Start the instance (if stopped):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/start.sh
|
||||
```
|
||||
|
||||
Run a command on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "<powershell-command>"
|
||||
```
|
||||
|
||||
Sync the current git branch and rebuild:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/sync.sh
|
||||
```
|
||||
|
||||
Stop the instance when done (avoids cost):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/stop.sh
|
||||
```
|
||||
|
||||
### Common Workflows
|
||||
|
||||
Run unit tests on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test --manifest-path cli\Cargo.toml"
|
||||
```
|
||||
|
||||
Run e2e tests on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test e2e --manifest-path cli\Cargo.toml -- --ignored --test-threads=1"
|
||||
```
|
||||
|
||||
Check bootstrap progress (first boot only):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "Get-Content C:\bootstrap.log"
|
||||
```
|
||||
|
||||
The repo lives at `C:\agent-browser` on the instance. Rust, Git, and Chrome are pre-installed. The `run.sh` wrapper automatically adds cargo and git to PATH.
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
node "%SCRIPT_DIR%..\dist\index.js" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -1 +0,0 @@
|
||||
/Users/leo/github.com/agent-browser/cli/target/release/agent-browser: /Users/leo/github.com/agent-browser/cli/build.rs /Users/leo/github.com/agent-browser/cli/cdp-protocol/browser_protocol.json /Users/leo/github.com/agent-browser/cli/cdp-protocol/js_protocol.json /Users/leo/github.com/agent-browser/cli/src/color.rs /Users/leo/github.com/agent-browser/cli/src/commands.rs /Users/leo/github.com/agent-browser/cli/src/connection.rs /Users/leo/github.com/agent-browser/cli/src/flags.rs /Users/leo/github.com/agent-browser/cli/src/install.rs /Users/leo/github.com/agent-browser/cli/src/main.rs /Users/leo/github.com/agent-browser/cli/src/output.rs /Users/leo/github.com/agent-browser/cli/src/validation.rs
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Cross-platform CLI wrapper for agent-browser
|
||||
*
|
||||
* This wrapper enables npx support on Windows where shell scripts don't work.
|
||||
* For global installs, postinstall.js patches the shims to invoke the native
|
||||
* binary directly (zero overhead).
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { existsSync, accessSync, chmodSync, constants } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { platform, arch } from 'os';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Detect if the system uses musl libc (e.g. Alpine Linux)
|
||||
function isMusl() {
|
||||
if (platform() !== 'linux') return false;
|
||||
try {
|
||||
const result = execSync('ldd --version 2>&1 || true', { encoding: 'utf8' });
|
||||
return result.toLowerCase().includes('musl');
|
||||
} catch {
|
||||
return existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1');
|
||||
}
|
||||
}
|
||||
|
||||
// Map Node.js platform/arch to binary naming convention
|
||||
function getBinaryName() {
|
||||
const os = platform();
|
||||
const cpuArch = arch();
|
||||
|
||||
let osKey;
|
||||
switch (os) {
|
||||
case 'darwin':
|
||||
osKey = 'darwin';
|
||||
break;
|
||||
case 'linux':
|
||||
osKey = isMusl() ? 'linux-musl' : 'linux';
|
||||
break;
|
||||
case 'win32':
|
||||
osKey = 'win32';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
let archKey;
|
||||
switch (cpuArch) {
|
||||
case 'x64':
|
||||
case 'x86_64':
|
||||
archKey = 'x64';
|
||||
break;
|
||||
case 'arm64':
|
||||
case 'aarch64':
|
||||
archKey = 'arm64';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = os === 'win32' ? '.exe' : '';
|
||||
return `agent-browser-${osKey}-${archKey}${ext}`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const binaryName = getBinaryName();
|
||||
|
||||
if (!binaryName) {
|
||||
console.error(`Error: Unsupported platform: ${platform()}-${arch()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binaryPath = join(__dirname, binaryName);
|
||||
|
||||
if (!existsSync(binaryPath)) {
|
||||
console.error(`Error: No binary found for ${platform()}-${arch()}`);
|
||||
console.error(`Expected: ${binaryPath}`);
|
||||
console.error('');
|
||||
console.error('Run "npm run build:native" to build for your platform,');
|
||||
console.error('or reinstall the package to trigger the postinstall download.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure binary is executable (fixes EACCES on macOS/Linux when postinstall didn't run,
|
||||
// e.g., when using bun which blocks lifecycle scripts by default)
|
||||
if (platform() !== 'win32') {
|
||||
try {
|
||||
accessSync(binaryPath, constants.X_OK);
|
||||
} catch {
|
||||
// Binary exists but isn't executable - fix it
|
||||
try {
|
||||
chmodSync(binaryPath, 0o755);
|
||||
} catch (chmodErr) {
|
||||
console.error(`Error: Cannot make binary executable: ${chmodErr.message}`);
|
||||
console.error('Try running: chmod +x ' + binaryPath);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the native binary with inherited stdio
|
||||
const child = spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(`Error executing binary: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
Generated
+12
-2879
File diff suppressed because it is too large
Load Diff
+2
-42
@@ -1,44 +1,13 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.24.0-fork.1"
|
||||
name = "agent-browser"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/leeguooooo/agent-browser-stealth"
|
||||
homepage = "https://github.com/leeguooooo/agent-browser-stealth"
|
||||
readme = "../README.md"
|
||||
keywords = ["browser", "automation", "ai", "cdp", "chrome"]
|
||||
categories = ["command-line-utilities", "web-programming"]
|
||||
|
||||
[[bin]]
|
||||
name = "agent-browser"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex-lite = "0.1"
|
||||
dirs = "5.0"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
image = "0.25"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
|
||||
sha2 = "0.10"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
socket2 = "0.6"
|
||||
similar = "2"
|
||||
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
chrono = "0.4"
|
||||
urlencoding = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -46,17 +15,8 @@ libc = "0.2"
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_System_Threading", "Win32_Foundation"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[profile.ci]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
|
||||
-481
@@ -1,481 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let protocol_dir = Path::new("cdp-protocol");
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let out_path = Path::new(&out_dir).join("cdp_generated.rs");
|
||||
|
||||
let browser_path = protocol_dir.join("browser_protocol.json");
|
||||
let js_path = protocol_dir.join("js_protocol.json");
|
||||
|
||||
if !browser_path.exists() && !js_path.exists() {
|
||||
fs::write(
|
||||
&out_path,
|
||||
"// No protocol JSON files found in cdp-protocol/\n",
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut all_domains: Vec<Domain> = Vec::new();
|
||||
|
||||
for path in [&browser_path, &js_path] {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let protocol: ProtocolSpec = match serde_json::from_str(&content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Failed to parse {}: {}", path.display(), e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
all_domains.extend(protocol.domains);
|
||||
}
|
||||
|
||||
// Collect all known type IDs per domain for cross-domain resolution
|
||||
let mut domain_types: std::collections::HashMap<String, HashSet<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for domain in &all_domains {
|
||||
let mut types = HashSet::new();
|
||||
for td in &domain.types {
|
||||
types.insert(td.id.clone());
|
||||
}
|
||||
domain_types.insert(domain.domain.clone(), types);
|
||||
}
|
||||
|
||||
// Known recursive struct fields that need Box wrapping
|
||||
let recursive_fields: HashSet<(&str, &str, &str)> = [
|
||||
("DOM", "Node", "contentDocument"),
|
||||
("DOM", "Node", "templateContent"),
|
||||
("DOM", "Node", "importedDocument"),
|
||||
("Accessibility", "AXNode", "sources"),
|
||||
("Runtime", "StackTrace", "parent"),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
output.push_str("use serde::{Deserialize, Serialize};\n\n");
|
||||
|
||||
for domain in &all_domains {
|
||||
generate_domain(domain, &domain_types, &recursive_fields, &mut output);
|
||||
}
|
||||
|
||||
fs::write(&out_path, &output).unwrap();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ProtocolSpec {
|
||||
domains: Vec<Domain>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Domain {
|
||||
domain: String,
|
||||
#[serde(default)]
|
||||
types: Vec<TypeDef>,
|
||||
#[serde(default)]
|
||||
commands: Vec<Command>,
|
||||
#[serde(default)]
|
||||
events: Vec<Event>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct TypeDef {
|
||||
id: String,
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: String,
|
||||
#[serde(default)]
|
||||
properties: Vec<Property>,
|
||||
#[serde(rename = "enum", default)]
|
||||
enum_values: Vec<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Command {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
parameters: Vec<Property>,
|
||||
#[serde(default)]
|
||||
returns: Vec<Property>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Event {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
parameters: Vec<Property>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Property {
|
||||
name: String,
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: Option<String>,
|
||||
#[serde(rename = "$ref", default)]
|
||||
ref_type: Option<String>,
|
||||
#[serde(default)]
|
||||
optional: bool,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
items: Option<Box<ItemType>>,
|
||||
#[serde(rename = "enum", default)]
|
||||
enum_values: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct ItemType {
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: Option<String>,
|
||||
#[serde(rename = "$ref", default)]
|
||||
ref_type: Option<String>,
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut capitalize = true;
|
||||
for c in s.chars() {
|
||||
if c == '_' || c == '-' || c == '.' {
|
||||
capitalize = true;
|
||||
} else if capitalize {
|
||||
result.push(c.to_ascii_uppercase());
|
||||
capitalize = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn to_snake_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
if c.is_uppercase() && i > 0 {
|
||||
// Only insert underscore at transitions from lowercase to uppercase,
|
||||
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
|
||||
let prev_upper = chars[i - 1].is_uppercase();
|
||||
let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
|
||||
if !prev_upper || next_lower {
|
||||
result.push('_');
|
||||
}
|
||||
}
|
||||
result.push(c.to_ascii_lowercase());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve a $ref type reference. Cross-domain refs like "Page.FrameId" become
|
||||
/// `super::cdp_page::FrameId`. Same-domain refs are used directly.
|
||||
fn resolve_ref(
|
||||
r: &str,
|
||||
current_domain: &str,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
) -> String {
|
||||
let parts: Vec<&str> = r.split('.').collect();
|
||||
if parts.len() == 2 {
|
||||
let ref_domain = parts[0];
|
||||
let ref_type = parts[1];
|
||||
if ref_domain == current_domain {
|
||||
to_pascal_case(ref_type)
|
||||
} else {
|
||||
// Check if this type actually exists in the referenced domain
|
||||
if domain_types
|
||||
.get(ref_domain)
|
||||
.is_some_and(|t| t.contains(ref_type))
|
||||
{
|
||||
format!(
|
||||
"super::cdp_{}::{}",
|
||||
to_snake_case(ref_domain),
|
||||
to_pascal_case(ref_type)
|
||||
)
|
||||
} else {
|
||||
// Fall back to serde_json::Value for unknown cross-domain refs
|
||||
"serde_json::Value".to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
to_pascal_case(r)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_type_in_domain(
|
||||
prop: &Property,
|
||||
current_domain: &str,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
) -> String {
|
||||
if let Some(ref r) = prop.ref_type {
|
||||
let type_name = resolve_ref(r, current_domain, domain_types);
|
||||
if prop.optional {
|
||||
format!("Option<{}>", type_name)
|
||||
} else {
|
||||
type_name
|
||||
}
|
||||
} else if let Some(ref t) = prop.type_kind {
|
||||
let base = match t.as_str() {
|
||||
"string" => "String".to_string(),
|
||||
"integer" => "i64".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
"object" => "serde_json::Value".to_string(),
|
||||
"any" => "serde_json::Value".to_string(),
|
||||
"array" => {
|
||||
if let Some(ref items) = prop.items {
|
||||
let inner = if let Some(ref r) = items.ref_type {
|
||||
resolve_ref(r, current_domain, domain_types)
|
||||
} else {
|
||||
match items.type_kind.as_deref().unwrap_or("any") {
|
||||
"string" => "String".to_string(),
|
||||
"integer" => "i64".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
_ => "serde_json::Value".to_string(),
|
||||
}
|
||||
};
|
||||
format!("Vec<{}>", inner)
|
||||
} else {
|
||||
"Vec<serde_json::Value>".to_string()
|
||||
}
|
||||
}
|
||||
_ => "serde_json::Value".to_string(),
|
||||
};
|
||||
if prop.optional {
|
||||
format!("Option<{}>", base)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else if prop.optional {
|
||||
"Option<serde_json::Value>".to_string()
|
||||
} else {
|
||||
"serde_json::Value".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_rust_keyword(s: &str) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
"type"
|
||||
| "self"
|
||||
| "Self"
|
||||
| "super"
|
||||
| "move"
|
||||
| "ref"
|
||||
| "fn"
|
||||
| "mod"
|
||||
| "use"
|
||||
| "pub"
|
||||
| "let"
|
||||
| "mut"
|
||||
| "const"
|
||||
| "static"
|
||||
| "if"
|
||||
| "else"
|
||||
| "for"
|
||||
| "while"
|
||||
| "loop"
|
||||
| "match"
|
||||
| "return"
|
||||
| "break"
|
||||
| "continue"
|
||||
| "as"
|
||||
| "in"
|
||||
| "impl"
|
||||
| "trait"
|
||||
| "struct"
|
||||
| "enum"
|
||||
| "where"
|
||||
| "async"
|
||||
| "await"
|
||||
| "dyn"
|
||||
| "box"
|
||||
| "yield"
|
||||
| "override"
|
||||
| "crate"
|
||||
| "extern"
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_domain(
|
||||
domain: &Domain,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
recursive_fields: &HashSet<(&str, &str, &str)>,
|
||||
output: &mut String,
|
||||
) {
|
||||
let mod_name = to_snake_case(&domain.domain);
|
||||
output.push_str(&format!(
|
||||
"#[allow(dead_code, non_snake_case, non_camel_case_types, clippy::enum_variant_names)]\npub mod cdp_{} {{\n",
|
||||
mod_name
|
||||
));
|
||||
output.push_str(" use super::*;\n\n");
|
||||
|
||||
for type_def in &domain.types {
|
||||
if !type_def.enum_values.is_empty() {
|
||||
// Deduplicate enum variants (some CDP enums have duplicated PascalCase forms)
|
||||
let mut seen_variants = HashSet::new();
|
||||
output.push_str(" #[derive(Debug, Clone, Serialize, Deserialize)]\n");
|
||||
output.push_str(&format!(" pub enum {} {{\n", type_def.id));
|
||||
for val in &type_def.enum_values {
|
||||
let mut variant = to_pascal_case(val);
|
||||
if variant == "Self" {
|
||||
variant = "SelfValue".to_string();
|
||||
}
|
||||
if variant.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
variant = format!("V{}", variant);
|
||||
}
|
||||
if seen_variants.insert(variant.clone()) {
|
||||
output.push_str(&format!(
|
||||
" #[serde(rename = \"{}\")]\n {},\n",
|
||||
val, variant
|
||||
));
|
||||
}
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
} else if type_def.type_kind == "object" && !type_def.properties.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {} {{\n", type_def.id));
|
||||
for prop in &type_def.properties {
|
||||
let field_name = to_snake_case(&prop.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let mut rust_type = map_type_in_domain(prop, &domain.domain, domain_types);
|
||||
|
||||
// Wrap recursive fields in Box
|
||||
if recursive_fields.contains(&(
|
||||
domain.domain.as_str(),
|
||||
type_def.id.as_str(),
|
||||
prop.name.as_str(),
|
||||
)) {
|
||||
if rust_type.starts_with("Option<") {
|
||||
let inner = &rust_type[7..rust_type.len() - 1];
|
||||
rust_type = format!("Option<Box<{}>>", inner);
|
||||
} else {
|
||||
rust_type = format!("Box<{}>", rust_type);
|
||||
}
|
||||
}
|
||||
|
||||
if prop.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
} else if type_def.type_kind == "object" && type_def.properties.is_empty() {
|
||||
output.push_str(&format!(
|
||||
" pub type {} = serde_json::Value;\n\n",
|
||||
type_def.id
|
||||
));
|
||||
} else if type_def.type_kind == "array" {
|
||||
output.push_str(&format!(
|
||||
" pub type {} = Vec<serde_json::Value>;\n\n",
|
||||
type_def.id
|
||||
));
|
||||
} else if type_def.type_kind == "string" && type_def.enum_values.is_empty() {
|
||||
output.push_str(&format!(" pub type {} = String;\n\n", type_def.id));
|
||||
} else if type_def.type_kind == "integer" {
|
||||
output.push_str(&format!(" pub type {} = i64;\n\n", type_def.id));
|
||||
} else if type_def.type_kind == "number" {
|
||||
output.push_str(&format!(" pub type {} = f64;\n\n", type_def.id));
|
||||
}
|
||||
}
|
||||
|
||||
for cmd in &domain.commands {
|
||||
let pascal_name = to_pascal_case(&cmd.name);
|
||||
|
||||
if !cmd.parameters.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Params {{\n", pascal_name));
|
||||
for param in &cmd.parameters {
|
||||
let field_name = to_snake_case(¶m.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
|
||||
if param.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
|
||||
if !cmd.returns.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Result {{\n", pascal_name));
|
||||
for ret in &cmd.returns {
|
||||
let field_name = to_snake_case(&ret.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(ret, &domain.domain, domain_types);
|
||||
if ret.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
for event in &domain.events {
|
||||
if !event.parameters.is_empty() {
|
||||
let pascal_name = to_pascal_case(&event.name);
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Event {{\n", pascal_name));
|
||||
for param in &event.parameters {
|
||||
let field_name = to_snake_case(¶m.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
|
||||
if param.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str("}\n\n");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+236
-2874
File diff suppressed because it is too large
Load Diff
+119
-547
@@ -26,8 +26,6 @@ pub struct Response {
|
||||
pub success: bool,
|
||||
pub data: Option<Value>,
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -83,68 +81,25 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the base directory for socket/pid files.
|
||||
/// Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.agent-browser > tmpdir
|
||||
pub fn get_socket_dir() -> PathBuf {
|
||||
// 1. Explicit override (ignore empty string)
|
||||
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. XDG_RUNTIME_DIR (Linux standard, ignore empty string)
|
||||
if let Ok(runtime_dir) = env::var("XDG_RUNTIME_DIR") {
|
||||
if !runtime_dir.is_empty() {
|
||||
return PathBuf::from(runtime_dir).join("agent-browser");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Home directory fallback (like Docker Desktop's ~/.docker/run/)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".agent-browser");
|
||||
}
|
||||
|
||||
// 4. Last resort: temp dir
|
||||
env::temp_dir().join("agent-browser")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn get_socket_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.sock", session))
|
||||
let tmp = env::temp_dir();
|
||||
tmp.join(format!("agent-browser-{}.sock", session))
|
||||
}
|
||||
|
||||
fn get_pid_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.pid", session))
|
||||
}
|
||||
|
||||
/// Clean up stale socket and PID files for a session
|
||||
fn cleanup_stale_files(session: &str) {
|
||||
let pid_path = get_pid_path(session);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let stream_path = get_socket_dir().join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket_path = get_socket_path(session);
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port_path = get_port_path(session);
|
||||
let _ = fs::remove_file(&port_path);
|
||||
}
|
||||
let tmp = env::temp_dir();
|
||||
tmp.join(format!("agent-browser-{}.pid", session))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.port", session))
|
||||
let tmp = env::temp_dir();
|
||||
tmp.join(format!("agent-browser-{}.port", session))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn get_port_for_session(session: &str) -> u16 {
|
||||
fn get_port_for_session(session: &str) -> u16 {
|
||||
let mut hash: i32 = 0;
|
||||
for c in session.chars() {
|
||||
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
|
||||
@@ -154,19 +109,37 @@ pub fn get_port_for_session(session: &str) -> u16 {
|
||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||
}
|
||||
|
||||
/// Read the actual daemon port from the `.port` file written by the daemon.
|
||||
/// Falls back to the hash-derived port if the file does not exist or is
|
||||
/// unreadable (e.g. daemon has not started yet).
|
||||
#[cfg(windows)]
|
||||
pub fn resolve_port(session: &str) -> u16 {
|
||||
let port_path = get_port_path(session);
|
||||
fs::read_to_string(&port_path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u16>().ok())
|
||||
.unwrap_or_else(|| get_port_for_session(session))
|
||||
#[cfg(unix)]
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<i32>() {
|
||||
unsafe {
|
||||
return libc::kill(pid, 0) == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn daemon_ready(session: &str) -> bool {
|
||||
#[cfg(windows)]
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
let port = get_port_for_session(session);
|
||||
TcpStream::connect_timeout(
|
||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn daemon_ready(session: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket_path = get_socket_path(session);
|
||||
@@ -174,7 +147,7 @@ pub fn daemon_ready(session: &str) -> bool {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port = resolve_port(session);
|
||||
let port = get_port_for_session(session);
|
||||
TcpStream::connect_timeout(
|
||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
||||
Duration::from_millis(50),
|
||||
@@ -189,282 +162,120 @@ pub struct DaemonResult {
|
||||
pub already_running: bool,
|
||||
}
|
||||
|
||||
/// Options forwarded to the daemon process as environment variables.
|
||||
/// Note: `confirm_interactive` is intentionally absent -- it is a CLI-side
|
||||
/// UX concern (prompting the user on stdin) and not a daemon configuration.
|
||||
/// The daemon only needs `confirm_actions` to gate action categories.
|
||||
pub struct DaemonOptions<'a> {
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
pub executable_path: Option<&'a str>,
|
||||
pub extensions: &'a [String],
|
||||
pub args: Option<&'a str>,
|
||||
pub user_agent: Option<&'a str>,
|
||||
pub proxy: Option<&'a str>,
|
||||
pub proxy_bypass: Option<&'a str>,
|
||||
pub proxy_username: Option<&'a str>,
|
||||
pub proxy_password: Option<&'a str>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub allow_file_access: bool,
|
||||
pub profile: Option<&'a str>,
|
||||
pub state: Option<&'a str>,
|
||||
pub provider: Option<&'a str>,
|
||||
pub device: Option<&'a str>,
|
||||
pub session_name: Option<&'a str>,
|
||||
pub download_path: Option<&'a str>,
|
||||
pub allowed_domains: Option<&'a [String]>,
|
||||
pub action_policy: Option<&'a str>,
|
||||
pub confirm_actions: Option<&'a str>,
|
||||
pub engine: Option<&'a str>,
|
||||
pub auto_connect: bool,
|
||||
pub force_launch: bool,
|
||||
pub idle_timeout: Option<&'a str>,
|
||||
pub cdp: Option<&'a str>,
|
||||
pub no_auto_dialog: bool,
|
||||
}
|
||||
|
||||
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
if opts.headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
if opts.debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
if let Some(path) = opts.executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
if !opts.extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(","));
|
||||
}
|
||||
if let Some(a) = opts.args {
|
||||
cmd.env("AGENT_BROWSER_ARGS", a);
|
||||
}
|
||||
if let Some(ua) = opts.user_agent {
|
||||
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
|
||||
}
|
||||
if let Some(p) = opts.proxy {
|
||||
cmd.env("AGENT_BROWSER_PROXY", p);
|
||||
}
|
||||
if let Some(pb) = opts.proxy_bypass {
|
||||
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
|
||||
}
|
||||
if let Some(pu) = opts.proxy_username {
|
||||
cmd.env("AGENT_BROWSER_PROXY_USERNAME", pu);
|
||||
}
|
||||
if let Some(pp) = opts.proxy_password {
|
||||
cmd.env("AGENT_BROWSER_PROXY_PASSWORD", pp);
|
||||
}
|
||||
if opts.ignore_https_errors {
|
||||
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
|
||||
}
|
||||
if opts.allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
if let Some(prof) = opts.profile {
|
||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
||||
}
|
||||
if let Some(st) = opts.state {
|
||||
cmd.env("AGENT_BROWSER_STATE", st);
|
||||
}
|
||||
if let Some(p) = opts.provider {
|
||||
cmd.env("AGENT_BROWSER_PROVIDER", p);
|
||||
}
|
||||
if let Some(d) = opts.device {
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
if let Some(sn) = opts.session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(dp) = opts.download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
if let Some(ad) = opts.allowed_domains {
|
||||
cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(","));
|
||||
}
|
||||
if let Some(ap) = opts.action_policy {
|
||||
cmd.env("AGENT_BROWSER_ACTION_POLICY", ap);
|
||||
}
|
||||
if let Some(ca) = opts.confirm_actions {
|
||||
cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca);
|
||||
}
|
||||
if let Some(engine) = opts.engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
if opts.auto_connect {
|
||||
cmd.env("AGENT_BROWSER_AUTO_CONNECT", "1");
|
||||
}
|
||||
if opts.force_launch {
|
||||
cmd.env("AGENT_BROWSER_FORCE_LAUNCH", "1");
|
||||
}
|
||||
if let Some(idle) = opts.idle_timeout {
|
||||
cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle);
|
||||
}
|
||||
if let Some(cdp) = opts.cdp {
|
||||
cmd.env("AGENT_BROWSER_CDP", cdp);
|
||||
}
|
||||
if opts.no_auto_dialog {
|
||||
cmd.env("AGENT_BROWSER_NO_AUTO_DIALOG", "1");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
|
||||
// Socket connectivity is the sole liveness check — no PID check — so
|
||||
// callers in a different PID namespace (e.g. unshare) can still reuse
|
||||
// an existing daemon they can reach over the socket.
|
||||
if daemon_ready(session) {
|
||||
// Double-check it's actually responsive by waiting and checking again
|
||||
// This handles the race condition where daemon is shutting down
|
||||
// (daemon has a 100ms shutdown delay, so we wait longer)
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
if daemon_ready(session) {
|
||||
return Ok(DaemonResult {
|
||||
already_running: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any stale socket/pid files before starting fresh
|
||||
cleanup_stale_files(session);
|
||||
|
||||
// Ensure socket directory exists
|
||||
let socket_dir = get_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
fs::create_dir_all(&socket_dir)
|
||||
.map_err(|e| format!("Failed to create socket directory: {}", e))?;
|
||||
}
|
||||
|
||||
// Pre-flight check: Validate socket path length (Unix limit is 104 bytes including null terminator)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket_path = get_socket_path(session);
|
||||
let path_len = socket_path.as_os_str().len();
|
||||
if path_len > 103 {
|
||||
return Err(format!(
|
||||
"Session name '{}' is too long. Socket path would be {} bytes (max 103).\n\
|
||||
Use a shorter session name or set AGENT_BROWSER_SOCKET_DIR to a shorter path.",
|
||||
session, path_len
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight check: Verify socket directory is writable
|
||||
{
|
||||
let test_file = socket_dir.join(".write_test");
|
||||
match fs::write(&test_file, b"") {
|
||||
Ok(_) => {
|
||||
let _ = fs::remove_file(&test_file);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Socket directory '{}' is not writable: {}",
|
||||
socket_dir.display(),
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
pub fn ensure_daemon(
|
||||
session: &str,
|
||||
headed: bool,
|
||||
executable_path: Option<&str>,
|
||||
extensions: &[String],
|
||||
) -> Result<DaemonResult, String> {
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
return Ok(DaemonResult {
|
||||
already_running: true,
|
||||
});
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
||||
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
|
||||
let exe_dir = exe_path.parent().unwrap();
|
||||
|
||||
#[allow(unused_assignments)]
|
||||
let mut daemon_child: Option<std::process::Child> = None;
|
||||
let mut daemon_paths = vec![
|
||||
exe_dir.join("daemon.js"),
|
||||
exe_dir.join("../dist/daemon.js"),
|
||||
PathBuf::from("dist/daemon.js"),
|
||||
];
|
||||
|
||||
// Check AGENT_BROWSER_HOME environment variable
|
||||
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
|
||||
let home_path = PathBuf::from(&home);
|
||||
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
|
||||
daemon_paths.insert(1, home_path.join("daemon.js"));
|
||||
}
|
||||
|
||||
let daemon_path = daemon_paths
|
||||
.iter()
|
||||
.find(|p| p.exists())
|
||||
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
|
||||
|
||||
// Spawn daemon as a fully detached background process
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
if let Some(path) = executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
|
||||
if !extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
|
||||
}
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
// Create new session (detach from terminal)
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
daemon_child = Some(
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
);
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
|
||||
// and automatically quotes arguments containing spaces.
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
if let Some(path) = executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
|
||||
if !extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
|
||||
}
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
daemon_child = Some(
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
);
|
||||
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
if daemon_ready(session) {
|
||||
return Ok(DaemonResult {
|
||||
already_running: false,
|
||||
});
|
||||
return Ok(DaemonResult { already_running: false });
|
||||
}
|
||||
|
||||
// Detect early daemon exit and surface the real error from stderr
|
||||
if let Some(ref mut child) = daemon_child {
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
let mut stderr_output = String::new();
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
let _ = stderr.read_to_string(&mut stderr_output);
|
||||
}
|
||||
let stderr_trimmed = stderr_output.trim();
|
||||
if !stderr_trimmed.is_empty() {
|
||||
let msg = if stderr_trimmed.len() > 500 {
|
||||
let mut end = 500;
|
||||
while !stderr_trimmed.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&stderr_trimmed[..end]
|
||||
} else {
|
||||
stderr_trimmed
|
||||
};
|
||||
return Err(format!("Daemon process exited during startup:\n{}", msg));
|
||||
}
|
||||
return Err(
|
||||
"Daemon process exited during startup with no error output. \
|
||||
Re-run with --debug for more details."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let endpoint_info = format!(
|
||||
"socket: {}",
|
||||
get_socket_dir().join(format!("{}.sock", session)).display()
|
||||
);
|
||||
#[cfg(windows)]
|
||||
let endpoint_info = format!("port: 127.0.0.1:{}", resolve_port(session));
|
||||
|
||||
Err(format!("Daemon failed to start ({})", endpoint_info))
|
||||
Err("Daemon failed to start".to_string())
|
||||
}
|
||||
|
||||
fn connect(session: &str) -> Result<Connection, String> {
|
||||
@@ -477,7 +288,7 @@ fn connect(session: &str) -> Result<Connection, String> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port = resolve_port(session);
|
||||
let port = get_port_for_session(session);
|
||||
TcpStream::connect(format!("127.0.0.1:{}", port))
|
||||
.map(Connection::Tcp)
|
||||
.map_err(|e| format!("Failed to connect: {}", e))
|
||||
@@ -485,67 +296,12 @@ fn connect(session: &str) -> Result<Connection, String> {
|
||||
}
|
||||
|
||||
pub fn send_command(cmd: Value, session: &str) -> Result<Response, String> {
|
||||
// Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues)
|
||||
const MAX_RETRIES: u32 = 5;
|
||||
const RETRY_DELAY_MS: u64 = 200;
|
||||
|
||||
let mut last_error = String::new();
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
if attempt > 0 {
|
||||
thread::sleep(Duration::from_millis(RETRY_DELAY_MS * (attempt as u64)));
|
||||
}
|
||||
|
||||
match send_command_once(&cmd, session) {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e) => {
|
||||
if is_transient_error(&e) {
|
||||
last_error = e;
|
||||
continue;
|
||||
}
|
||||
// Non-transient error, fail immediately
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"{} (after {} retries - daemon may be busy or unresponsive)",
|
||||
last_error, MAX_RETRIES
|
||||
))
|
||||
}
|
||||
|
||||
/// Check if an error is transient and worth retrying.
|
||||
/// Transient errors include:
|
||||
/// - EAGAIN/EWOULDBLOCK (os error 35 on macOS, 11 on Linux)
|
||||
/// - EOF errors (daemon closed connection before responding)
|
||||
/// - Connection reset/broken pipe (daemon crashed or restarting)
|
||||
/// - Connection refused/socket not found (daemon still starting)
|
||||
fn is_transient_error(error: &str) -> bool {
|
||||
error.contains("os error 35") // EAGAIN on macOS
|
||||
|| error.contains("os error 11") // EAGAIN on Linux
|
||||
|| error.contains("WouldBlock")
|
||||
|| error.contains("Resource temporarily unavailable")
|
||||
|| error.contains("EOF")
|
||||
|| error.contains("line 1 column 0") // Empty JSON response
|
||||
|| error.contains("Connection reset")
|
||||
|| error.contains("Broken pipe")
|
||||
|| error.contains("os error 54") // Connection reset by peer (macOS)
|
||||
|| error.contains("os error 104") // Connection reset by peer (Linux)
|
||||
|| error.contains("os error 2") // No such file or directory (socket gone)
|
||||
|| error.contains("os error 61") // Connection refused (macOS)
|
||||
|| error.contains("os error 111") // Connection refused (Linux)
|
||||
|| error.contains("os error 10061") // Connection refused (Windows)
|
||||
|| error.contains("os error 10054") // Connection reset by peer (Windows)
|
||||
}
|
||||
|
||||
fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
|
||||
let mut stream = connect(session)?;
|
||||
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30))).ok();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5))).ok();
|
||||
|
||||
let mut json_str = serde_json::to_string(cmd).map_err(|e| e.to_string())?;
|
||||
let mut json_str = serde_json::to_string(&cmd).map_err(|e| e.to_string())?;
|
||||
json_str.push('\n');
|
||||
|
||||
stream
|
||||
@@ -560,187 +316,3 @@ fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
|
||||
|
||||
serde_json::from_str(&response_line).map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_explicit_override() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert_eq!(get_socket_dir(), PathBuf::from("/custom/socket/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_ignores_empty_socket_dir() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
.ends_with(".agent-browser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.set("XDG_RUNTIME_DIR", "/run/user/1000");
|
||||
|
||||
assert_eq!(
|
||||
get_socket_dir(),
|
||||
PathBuf::from("/run/user/1000/agent-browser")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_ignores_empty_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.set("XDG_RUNTIME_DIR", "");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
.ends_with(".agent-browser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_home_fallback() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let result = get_socket_dir();
|
||||
assert!(result.to_string_lossy().ends_with(".agent-browser"));
|
||||
assert!(
|
||||
result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users")
|
||||
);
|
||||
}
|
||||
|
||||
// === Transient Error Detection Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_eagain_macos() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to read: Resource temporarily unavailable (os error 35)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_eagain_linux() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to read: Resource temporarily unavailable (os error 11)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_would_block() {
|
||||
assert!(is_transient_error("operation WouldBlock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_resource_unavailable() {
|
||||
assert!(is_transient_error("Resource temporarily unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_eof() {
|
||||
assert!(is_transient_error(
|
||||
"Invalid response: EOF while parsing a value at line 1 column 0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_empty_json() {
|
||||
assert!(is_transient_error(
|
||||
"Invalid response: expected value at line 1 column 0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset() {
|
||||
assert!(is_transient_error("Connection reset by peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_broken_pipe() {
|
||||
assert!(is_transient_error("Broken pipe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset_macos() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to send: Connection reset by peer (os error 54)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset_linux() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to send: Connection reset by peer (os error 104)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_socket_not_found() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: No such file or directory (os error 2)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_refused_macos() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: Connection refused (os error 61)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_refused_linux() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: Connection refused (os error 111)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_refused_windows() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: No connection could be made because the target machine actively refused it. (os error 10061)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset_windows() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to send: An existing connection was forcibly closed by the remote host. (os error 10054)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_non_transient() {
|
||||
// These should NOT be considered transient
|
||||
assert!(!is_transient_error("Unknown command: foo"));
|
||||
assert!(!is_transient_error("Invalid JSON syntax"));
|
||||
assert!(!is_transient_error("Permission denied"));
|
||||
assert!(!is_transient_error("Daemon not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn test_get_port_for_session() {
|
||||
assert_eq!(get_port_for_session("default"), 50838);
|
||||
assert_eq!(get_port_for_session("my-session"), 63105);
|
||||
assert_eq!(get_port_for_session("work"), 51184);
|
||||
assert_eq!(get_port_for_session(""), 49152);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1240
File diff suppressed because it is too large
Load Diff
+151
-766
@@ -1,671 +1,177 @@
|
||||
use crate::color;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{exit, Command, Stdio};
|
||||
|
||||
const LAST_KNOWN_GOOD_URL: &str =
|
||||
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
|
||||
|
||||
pub fn get_browsers_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".agent-browser")
|
||||
.join("browsers")
|
||||
}
|
||||
|
||||
pub fn find_installed_chrome() -> Option<PathBuf> {
|
||||
let browsers_dir = get_browsers_dir();
|
||||
let debug = std::env::var("AGENT_BROWSER_DEBUG").is_ok();
|
||||
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] home_dir={:?} browsers_dir={}",
|
||||
dirs::home_dir(),
|
||||
browsers_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !browsers_dir.exists() {
|
||||
if debug {
|
||||
let _ = writeln!(io::stderr(), "[chrome-search] browsers_dir does not exist");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(&browsers_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"Warning: cannot read Chrome cache directory {}: {}",
|
||||
browsers_dir.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut versions: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let matches = e
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.starts_with("chrome-"));
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] entry {:?} matches={}",
|
||||
e.file_name(),
|
||||
matches
|
||||
);
|
||||
}
|
||||
matches
|
||||
})
|
||||
.collect();
|
||||
|
||||
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||||
|
||||
for entry in versions {
|
||||
let dir = entry.path();
|
||||
if let Some(bin) = chrome_binary_in_dir(&dir) {
|
||||
let exists = bin.exists();
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] candidate {} exists={}",
|
||||
bin.display(),
|
||||
exists
|
||||
);
|
||||
}
|
||||
if exists {
|
||||
return Some(bin);
|
||||
}
|
||||
} else if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] no binary found in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if debug {
|
||||
let _ = writeln!(io::stderr(), "[chrome-search] no installed Chrome found");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app =
|
||||
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
||||
if app.exists() {
|
||||
return Some(app);
|
||||
}
|
||||
let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
||||
if inner.exists() {
|
||||
return Some(inner);
|
||||
}
|
||||
let inner_x64 = dir.join(
|
||||
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
);
|
||||
if inner_x64.exists() {
|
||||
return Some(inner_x64);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let bin = dir.join("chrome");
|
||||
if bin.exists() {
|
||||
return Some(bin);
|
||||
}
|
||||
let inner = dir.join("chrome-linux64/chrome");
|
||||
if inner.exists() {
|
||||
return Some(inner);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bin = dir.join("chrome.exe");
|
||||
if bin.exists() {
|
||||
return Some(bin);
|
||||
}
|
||||
let inner = dir.join("chrome-win64/chrome.exe");
|
||||
if inner.exists() {
|
||||
return Some(inner);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_key() -> &'static str {
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
"mac-arm64"
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
"mac-x64"
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
"linux64"
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
"win64"
|
||||
}
|
||||
#[cfg(not(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "macos", target_arch = "x86_64"),
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "windows", target_arch = "x86_64"),
|
||||
)))]
|
||||
{
|
||||
// Compiles on unsupported platforms (e.g. linux aarch64) so the binary
|
||||
// can still be used for other commands like `connect`. The install path
|
||||
// guards against this at runtime before calling platform_key().
|
||||
panic!("Unsupported platform for Chrome for Testing download")
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_download_url() -> Result<(String, String), String> {
|
||||
let resp = reqwest::get(LAST_KNOWN_GOOD_URL)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse version info: {}", e))?;
|
||||
|
||||
let channel = body
|
||||
.get("channels")
|
||||
.and_then(|c| c.get("Stable"))
|
||||
.ok_or("No Stable channel found in version info")?;
|
||||
|
||||
let version = channel
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No version string found")?
|
||||
.to_string();
|
||||
|
||||
let platform = platform_key();
|
||||
|
||||
let url = channel
|
||||
.get("downloads")
|
||||
.and_then(|d| d.get("chrome"))
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find_map(|entry| {
|
||||
if entry.get("platform")?.as_str()? == platform {
|
||||
Some(entry.get("url")?.as_str()?.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| format!("No download URL found for platform: {}", platform))?;
|
||||
|
||||
Ok((version, url))
|
||||
}
|
||||
|
||||
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| format!("Download failed: {}", e))?;
|
||||
|
||||
let total = resp.content_length();
|
||||
let mut bytes = Vec::new();
|
||||
let mut stream = resp;
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut last_pct: u64 = 0;
|
||||
|
||||
loop {
|
||||
let chunk = stream
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| format!("Download error: {}", e))?;
|
||||
match chunk {
|
||||
Some(data) => {
|
||||
downloaded += data.len() as u64;
|
||||
bytes.extend_from_slice(&data);
|
||||
|
||||
if let Some(total) = total {
|
||||
let pct = (downloaded * 100) / total;
|
||||
if pct >= last_pct + 5 {
|
||||
last_pct = pct;
|
||||
let mb = downloaded as f64 / 1_048_576.0;
|
||||
let total_mb = total as f64 / 1_048_576.0;
|
||||
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
|
||||
let _ = io::stderr().flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
|
||||
let cursor = io::Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
|
||||
|
||||
let enclosed = match file.enclosed_name() {
|
||||
Some(name) => name.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
let raw_name = enclosed.to_string_lossy().to_string();
|
||||
// Strip the top-level "chrome-<platform>/" directory from zip entries.
|
||||
// On Windows, enclosed_name() normalizes paths to backslashes, so we
|
||||
// must split on either separator.
|
||||
let rel_path = raw_name
|
||||
.strip_prefix("chrome-")
|
||||
.and_then(|s| s.find(['/', '\\']).map(|i| &s[i + 1..]))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(raw_name.clone());
|
||||
|
||||
if rel_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let out_path = dest.join(&rel_path);
|
||||
|
||||
// Defense-in-depth: ensure the resolved path is inside dest
|
||||
if !out_path.starts_with(dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
format!("Failed to create parent dir {}: {}", parent.display(), e)
|
||||
})?;
|
||||
}
|
||||
let mut out_file = fs::File::create(&out_path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
||||
io::copy(&mut file, &mut out_file)
|
||||
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = file.unix_mode() {
|
||||
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_install(with_deps: bool) {
|
||||
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
|
||||
eprintln!(
|
||||
"{} Chrome for Testing does not provide Linux ARM64 builds.",
|
||||
color::error_indicator()
|
||||
);
|
||||
eprintln!(" Install Chromium from your system package manager instead:");
|
||||
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
|
||||
eprintln!(" sudo dnf install chromium # Fedora");
|
||||
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
|
||||
if is_linux {
|
||||
if with_deps {
|
||||
install_linux_deps();
|
||||
println!("{}", color::cyan("Installing system dependencies..."));
|
||||
|
||||
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
||||
let libasound = if package_exists_apt("libasound2t64") {
|
||||
"libasound2t64"
|
||||
} else {
|
||||
"libasound2"
|
||||
};
|
||||
|
||||
(
|
||||
"apt-get",
|
||||
vec![
|
||||
"libxcb-shm0",
|
||||
"libx11-xcb1",
|
||||
"libx11-6",
|
||||
"libxcb1",
|
||||
"libxext6",
|
||||
"libxrandr2",
|
||||
"libxcomposite1",
|
||||
"libxcursor1",
|
||||
"libxdamage1",
|
||||
"libxfixes3",
|
||||
"libxi6",
|
||||
"libgtk-3-0",
|
||||
"libpangocairo-1.0-0",
|
||||
"libpango-1.0-0",
|
||||
"libatk1.0-0",
|
||||
"libcairo-gobject2",
|
||||
"libcairo2",
|
||||
"libgdk-pixbuf-2.0-0",
|
||||
"libxrender1",
|
||||
libasound,
|
||||
"libfreetype6",
|
||||
"libfontconfig1",
|
||||
"libdbus-1-3",
|
||||
"libnss3",
|
||||
"libnspr4",
|
||||
"libatk-bridge2.0-0",
|
||||
"libdrm2",
|
||||
"libxkbcommon0",
|
||||
"libatspi2.0-0",
|
||||
"libcups2",
|
||||
"libxshmfence1",
|
||||
"libgbm1",
|
||||
],
|
||||
)
|
||||
} else if which_exists("dnf") {
|
||||
(
|
||||
"dnf",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
"libxcb",
|
||||
"libX11-xcb",
|
||||
"libX11",
|
||||
"libXext",
|
||||
"libXcursor",
|
||||
"libXfixes",
|
||||
"libXi",
|
||||
"gtk3",
|
||||
"cairo-gobject",
|
||||
],
|
||||
)
|
||||
} else if which_exists("yum") {
|
||||
(
|
||||
"yum",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
eprintln!("{} No supported package manager found (apt-get, dnf, or yum)", color::error_indicator());
|
||||
exit(1);
|
||||
};
|
||||
|
||||
let install_cmd = match pkg_mgr {
|
||||
"apt-get" => {
|
||||
format!(
|
||||
"sudo apt-get update && sudo apt-get install -y {}",
|
||||
deps.join(" ")
|
||||
)
|
||||
}
|
||||
_ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")),
|
||||
};
|
||||
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("{} System dependencies installed", color::success_indicator())
|
||||
}
|
||||
Ok(_) => eprintln!(
|
||||
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
||||
color::warning_indicator()
|
||||
),
|
||||
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"{} Linux detected. If browser fails to launch, run:",
|
||||
color::warning_indicator()
|
||||
);
|
||||
println!("{} Linux detected. If browser fails to launch, run:", color::warning_indicator());
|
||||
println!(" agent-browser install --with-deps");
|
||||
println!(" or: npx playwright install-deps chromium");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", color::cyan("Installing Chrome..."));
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
});
|
||||
|
||||
let (version, url) = match rt.block_on(fetch_download_url()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let dest = get_browsers_dir().join(format!("chrome-{}", version));
|
||||
|
||||
if let Some(bin) = chrome_binary_in_dir(&dest) {
|
||||
if bin.exists() {
|
||||
println!(
|
||||
"{} Chrome {} is already installed",
|
||||
color::success_indicator(),
|
||||
version
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Downloading Chrome {} for {}", version, platform_key());
|
||||
println!(" {}", url);
|
||||
|
||||
let bytes = match rt.block_on(download_bytes(&url)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match extract_zip(bytes, &dest) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"{} Chrome {} installed successfully",
|
||||
color::success_indicator(),
|
||||
version
|
||||
);
|
||||
println!(" Location: {}", dest.display());
|
||||
println!("{}", color::cyan("Installing Chromium browser..."));
|
||||
|
||||
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
|
||||
// and Command::new() doesn't resolve .cmd files the way the shell does.
|
||||
// Pass the entire command as a single string to /c to handle paths with spaces.
|
||||
#[cfg(windows)]
|
||||
let status = Command::new("cmd")
|
||||
.args(["/c", "npx playwright install chromium"])
|
||||
.status();
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let status = Command::new("npx")
|
||||
.args(["playwright", "install", "chromium"])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("{} Chromium installed successfully", color::success_indicator());
|
||||
if is_linux && !with_deps {
|
||||
println!();
|
||||
println!(
|
||||
"{} If you see \"shared library\" errors when running, use:",
|
||||
color::yellow("Note:")
|
||||
);
|
||||
println!("{} If you see \"shared library\" errors when running, use:", color::yellow("Note:"));
|
||||
println!(" agent-browser install --with-deps");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
Ok(_) => {
|
||||
eprintln!("{} Failed to install browser", color::error_indicator());
|
||||
if is_linux {
|
||||
println!("{} Try installing system dependencies first:", color::yellow("Tip:"));
|
||||
println!(" agent-browser install --with-deps");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_install_status(status: io::Result<std::process::ExitStatus>) {
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!(
|
||||
"{} System dependencies installed",
|
||||
color::success_indicator()
|
||||
)
|
||||
Err(e) => {
|
||||
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
|
||||
eprintln!("Make sure Node.js is installed and npx is in your PATH");
|
||||
exit(1);
|
||||
}
|
||||
Ok(_) => eprintln!(
|
||||
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
||||
color::warning_indicator()
|
||||
),
|
||||
Err(e) => eprintln!(
|
||||
"{} Could not run install command: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_linux_deps() {
|
||||
println!("{}", color::cyan("Installing system dependencies..."));
|
||||
|
||||
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
||||
// On Ubuntu 24.04+, many libraries were renamed with a t64 suffix as
|
||||
// part of the 64-bit time_t transition. Using the old names can cause
|
||||
// apt to propose removing hundreds of system packages to resolve
|
||||
// conflicts. We check for the t64 variant first to avoid this.
|
||||
let apt_deps: Vec<&str> = vec![
|
||||
("libxcb-shm0", None),
|
||||
("libx11-xcb1", None),
|
||||
("libx11-6", None),
|
||||
("libxcb1", None),
|
||||
("libxext6", None),
|
||||
("libxrandr2", None),
|
||||
("libxcomposite1", None),
|
||||
("libxcursor1", None),
|
||||
("libxdamage1", None),
|
||||
("libxfixes3", None),
|
||||
("libxi6", None),
|
||||
("libgtk-3-0", Some("libgtk-3-0t64")),
|
||||
("libpangocairo-1.0-0", Some("libpangocairo-1.0-0t64")),
|
||||
("libpango-1.0-0", Some("libpango-1.0-0t64")),
|
||||
("libatk1.0-0", Some("libatk1.0-0t64")),
|
||||
("libcairo-gobject2", Some("libcairo-gobject2t64")),
|
||||
("libcairo2", Some("libcairo2t64")),
|
||||
("libgdk-pixbuf-2.0-0", Some("libgdk-pixbuf-2.0-0t64")),
|
||||
("libxrender1", None),
|
||||
("libasound2", Some("libasound2t64")),
|
||||
("libfreetype6", None),
|
||||
("libfontconfig1", None),
|
||||
("libdbus-1-3", Some("libdbus-1-3t64")),
|
||||
("libnss3", None),
|
||||
("libnspr4", None),
|
||||
("libatk-bridge2.0-0", Some("libatk-bridge2.0-0t64")),
|
||||
("libdrm2", None),
|
||||
("libxkbcommon0", None),
|
||||
("libatspi2.0-0", Some("libatspi2.0-0t64")),
|
||||
("libcups2", Some("libcups2t64")),
|
||||
("libxshmfence1", None),
|
||||
("libgbm1", None),
|
||||
// Fonts: without actual font files, pages render with missing glyphs
|
||||
// (tofu). This is especially visible for CJK and emoji characters.
|
||||
("fonts-noto-color-emoji", None),
|
||||
("fonts-noto-cjk", None),
|
||||
("fonts-freefont-ttf", None),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(base, t64_variant)| {
|
||||
if let Some(t64) = t64_variant {
|
||||
if package_exists_apt(t64) {
|
||||
return t64;
|
||||
}
|
||||
}
|
||||
base
|
||||
})
|
||||
.collect();
|
||||
|
||||
("apt-get", apt_deps)
|
||||
} else if which_exists("dnf") {
|
||||
(
|
||||
"dnf",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
"libxcb",
|
||||
"libX11-xcb",
|
||||
"libX11",
|
||||
"libXext",
|
||||
"libXcursor",
|
||||
"libXfixes",
|
||||
"libXi",
|
||||
"gtk3",
|
||||
"cairo-gobject",
|
||||
// Fonts
|
||||
"google-noto-cjk-fonts",
|
||||
"google-noto-emoji-color-fonts",
|
||||
"liberation-fonts",
|
||||
],
|
||||
)
|
||||
} else if which_exists("yum") {
|
||||
(
|
||||
"yum",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
// Fonts
|
||||
"google-noto-cjk-fonts",
|
||||
"liberation-fonts",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} No supported package manager found (apt-get, dnf, or yum)",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(1);
|
||||
};
|
||||
|
||||
if pkg_mgr == "apt-get" {
|
||||
// Run apt-get update first
|
||||
println!("Running: sudo apt-get update");
|
||||
let update_status = Command::new("sudo").args(["apt-get", "update"]).status();
|
||||
|
||||
match update_status {
|
||||
Ok(s) if !s.success() => {
|
||||
eprintln!(
|
||||
"{} apt-get update failed. Continuing with existing package lists.",
|
||||
color::warning_indicator()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not run apt-get update: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Simulate the install first to detect if apt would remove any
|
||||
// packages. This prevents the catastrophic scenario where installing
|
||||
// these libraries triggers removal of hundreds of system packages
|
||||
// due to dependency conflicts (e.g. on Ubuntu 24.04 with the
|
||||
// t64 transition).
|
||||
println!("Checking for conflicts...");
|
||||
let sim_output = Command::new("sudo")
|
||||
.args(["apt-get", "install", "--simulate"])
|
||||
.args(&deps)
|
||||
.output();
|
||||
|
||||
match sim_output {
|
||||
Ok(output) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{}\n{}", stdout, stderr);
|
||||
|
||||
// Count packages that would be removed
|
||||
let removals: Vec<&str> = combined
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("Remv "))
|
||||
.collect();
|
||||
|
||||
if !removals.is_empty() {
|
||||
eprintln!(
|
||||
"{} Aborting: apt would remove {} package(s) to install these dependencies.",
|
||||
color::error_indicator(),
|
||||
removals.len()
|
||||
);
|
||||
eprintln!(
|
||||
" This usually means some package names have changed on your system"
|
||||
);
|
||||
eprintln!(" (e.g. Ubuntu 24.04 renamed libraries with a t64 suffix).");
|
||||
eprintln!();
|
||||
eprintln!(" Packages that would be removed:");
|
||||
for line in removals.iter().take(20) {
|
||||
eprintln!(" {}", line);
|
||||
}
|
||||
if removals.len() > 20 {
|
||||
eprintln!(" ... and {} more", removals.len() - 20);
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(" To install dependencies manually, run:");
|
||||
eprintln!(" sudo apt-get install {}", deps.join(" "));
|
||||
eprintln!();
|
||||
eprintln!(" Review the apt output carefully before confirming.");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not simulate install ({}). Proceeding with caution.",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Safe to proceed: no removals detected
|
||||
let install_cmd = format!("sudo apt-get install -y {}", deps.join(" "));
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sudo")
|
||||
.args(["apt-get", "install", "-y"])
|
||||
.args(&deps)
|
||||
.status();
|
||||
|
||||
report_install_status(status);
|
||||
} else {
|
||||
// dnf / yum path — these package managers do not remove packages
|
||||
// during install, so the simulate-first guard is not needed.
|
||||
let install_cmd = format!("sudo {} install -y {}", pkg_mgr, deps.join(" "));
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
||||
|
||||
report_install_status(status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,124 +208,3 @@ fn package_exists_apt(pkg: &str) -> bool {
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn get_dashboard_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".agent-browser")
|
||||
.join("dashboard")
|
||||
}
|
||||
|
||||
const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
fn dashboard_download_url() -> String {
|
||||
format!(
|
||||
"https://github.com/vercel-labs/agent-browser/releases/download/v{}/dashboard.zip",
|
||||
DASHBOARD_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_dashboard_install() {
|
||||
println!("{}", color::cyan("Installing dashboard..."));
|
||||
|
||||
let dest = get_dashboard_dir();
|
||||
|
||||
if dest.join("index.html").exists() {
|
||||
println!(
|
||||
"{} Dashboard is already installed at {}",
|
||||
color::success_indicator(),
|
||||
dest.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let url = dashboard_download_url();
|
||||
println!(" Downloading dashboard v{}", DASHBOARD_VERSION);
|
||||
println!(" {}", url);
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
});
|
||||
|
||||
let bytes = match rt.block_on(download_bytes(&url)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
eprintln!(" The dashboard may not be available for this version yet.");
|
||||
eprintln!(" You can build it locally: cd packages/dashboard && pnpm build");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match extract_dashboard_zip(bytes, &dest) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"{} Dashboard v{} installed successfully",
|
||||
color::success_indicator(),
|
||||
DASHBOARD_VERSION
|
||||
);
|
||||
println!(" Location: {}", dest.display());
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_dashboard_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
|
||||
let cursor = io::Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
|
||||
|
||||
let enclosed = match file.enclosed_name() {
|
||||
Some(name) => name.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
let rel_path = enclosed.to_string_lossy().to_string();
|
||||
|
||||
if rel_path.is_empty() || file.is_dir() {
|
||||
if file.is_dir() {
|
||||
let out_dir = dest.join(&rel_path);
|
||||
let _ = fs::create_dir_all(&out_dir);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let out_path = dest.join(&rel_path);
|
||||
if !out_path.starts_with(dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create parent dir {}: {}", parent.display(), e))?;
|
||||
}
|
||||
let mut out_file = fs::File::create(&out_path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
||||
io::copy(&mut file, &mut out_file)
|
||||
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+122
-1184
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,556 +0,0 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthProfile {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub submit_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: Option<String>,
|
||||
}
|
||||
|
||||
// Keep legacy Credential alias for backward compatibility
|
||||
pub type Credential = AuthProfile;
|
||||
|
||||
fn validate_profile_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid profile name '{}'. Must match /^[a-zA-Z0-9_-]+$/",
|
||||
name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_auth_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("auth")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("auth")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile_path(name: &str) -> PathBuf {
|
||||
get_auth_dir().join(format!("{}.json", name))
|
||||
}
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
const KEY_FILE_NAME: &str = ".encryption-key";
|
||||
|
||||
fn get_agent_browser_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_key_file_path() -> PathBuf {
|
||||
get_agent_browser_dir().join(KEY_FILE_NAME)
|
||||
}
|
||||
|
||||
fn parse_key_hex(hex_str: &str) -> Option<Vec<u8>> {
|
||||
let hex_str = hex_str.trim();
|
||||
if hex_str.len() != 64 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = (0..32)
|
||||
.map(|i| u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).unwrap())
|
||||
.collect();
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Read the encryption key from AGENT_BROWSER_ENCRYPTION_KEY env var or
|
||||
/// ~/.agent-browser/.encryption-key file (matching the Node.js implementation).
|
||||
fn get_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key_hex) = std::env::var(ENCRYPTION_KEY_ENV) {
|
||||
return parse_key_hex(&key_hex).ok_or_else(|| {
|
||||
format!(
|
||||
"{} should be a 64-character hex string (256 bits). Generate one with: openssl rand -hex 32",
|
||||
ENCRYPTION_KEY_ENV
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
if key_file.exists() {
|
||||
let hex = fs::read_to_string(&key_file)
|
||||
.map_err(|e| format!("Failed to read encryption key file: {}", e))?;
|
||||
return parse_key_hex(&hex).ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid encryption key in {}. Expected 64-character hex string.",
|
||||
key_file.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Encryption key required. Set {} or ensure {} exists.",
|
||||
ENCRYPTION_KEY_ENV,
|
||||
key_file.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Ensure an encryption key exists, auto-generating one if needed.
|
||||
fn ensure_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key) = get_encryption_key() {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
getrandom::getrandom(&mut key).map_err(|e| format!("Failed to generate key: {}", e))?;
|
||||
let key_hex = key.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||
|
||||
let dir = get_agent_browser_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
fs::write(&key_file, format!("{}\n", key_hex))
|
||||
.map_err(|e| format!("Failed to write encryption key: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&key_file, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[agent-browser] Auto-generated encryption key at {} -- back up this file or set {}",
|
||||
key_file.display(),
|
||||
ENCRYPTION_KEY_ENV
|
||||
);
|
||||
|
||||
Ok(key.to_vec())
|
||||
}
|
||||
|
||||
/// Encrypt a profile to the JSON+base64 format compatible with Node.js.
|
||||
fn encrypt_profile(profile: &AuthProfile) -> Result<String, String> {
|
||||
let key = ensure_encryption_key()?;
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?;
|
||||
|
||||
let plaintext = serde_json::to_string(profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
|
||||
let mut iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).map_err(|e| format!("Failed to generate IV: {}", e))?;
|
||||
|
||||
// aes_gcm appends the 16-byte auth tag to the ciphertext
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = json!({
|
||||
"version": 1,
|
||||
"encrypted": true,
|
||||
"iv": STANDARD.encode(iv),
|
||||
"authTag": STANDARD.encode(auth_tag),
|
||||
"data": STANDARD.encode(ciphertext),
|
||||
});
|
||||
|
||||
serde_json::to_string_pretty(&payload)
|
||||
.map_err(|e| format!("Failed to serialize payload: {}", e))
|
||||
}
|
||||
|
||||
/// JSON envelope written by Node.js encryption (src/encryption.ts).
|
||||
#[derive(Deserialize)]
|
||||
struct EncryptedPayload {
|
||||
#[allow(dead_code)]
|
||||
version: u32,
|
||||
#[allow(dead_code)]
|
||||
encrypted: bool,
|
||||
iv: String,
|
||||
#[serde(rename = "authTag")]
|
||||
auth_tag: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
|
||||
let text = std::str::from_utf8(data).map_err(|_| {
|
||||
"Profile is not valid UTF-8 -- it may use an older incompatible binary format".to_string()
|
||||
})?;
|
||||
|
||||
if let Ok(payload) = serde_json::from_str::<EncryptedPayload>(text) {
|
||||
let key = get_encryption_key()?;
|
||||
|
||||
let iv = STANDARD
|
||||
.decode(&payload.iv)
|
||||
.map_err(|e| format!("Invalid base64 iv: {}", e))?;
|
||||
let auth_tag = STANDARD
|
||||
.decode(&payload.auth_tag)
|
||||
.map_err(|e| format!("Invalid base64 authTag: {}", e))?;
|
||||
let ciphertext = STANDARD
|
||||
.decode(&payload.data)
|
||||
.map_err(|e| format!("Invalid base64 data: {}", e))?;
|
||||
|
||||
// aes_gcm expects ciphertext || auth_tag as input to decrypt
|
||||
let mut combined = Vec::with_capacity(ciphertext.len() + auth_tag.len());
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
combined.extend_from_slice(&auth_tag);
|
||||
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
|
||||
}
|
||||
|
||||
// Fallback: try as plain unencrypted JSON profile
|
||||
serde_json::from_str::<AuthProfile>(text)
|
||||
.map_err(|_| "Profile is not a valid encrypted or unencrypted payload".to_string())
|
||||
}
|
||||
|
||||
fn save_profile(profile: &AuthProfile) -> Result<(), String> {
|
||||
let dir = get_auth_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create auth dir: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let encrypted_json = encrypt_profile(profile)?;
|
||||
let path = get_profile_path(&profile.name);
|
||||
fs::write(&path, &encrypted_json).map_err(|e| format!("Failed to write profile: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_profile(name: &str) -> Result<AuthProfile, String> {
|
||||
let path = get_profile_path(name);
|
||||
if !path.exists() {
|
||||
return Err(format!("Auth profile '{}' not found", name));
|
||||
}
|
||||
let data = fs::read(&path).map_err(|e| format!("Failed to read profile: {}", e))?;
|
||||
decrypt_profile(&data)
|
||||
}
|
||||
|
||||
pub fn credentials_set(
|
||||
name: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
url: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = AuthProfile {
|
||||
name: name.to_string(),
|
||||
url: url.unwrap_or("").to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
}
|
||||
|
||||
pub fn auth_save(
|
||||
name: &str,
|
||||
url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
username_selector: Option<&str>,
|
||||
password_selector: Option<&str>,
|
||||
submit_selector: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = AuthProfile {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
username_selector: username_selector.map(String::from),
|
||||
password_selector: password_selector.map(String::from),
|
||||
submit_selector: submit_selector.map(String::from),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
}
|
||||
|
||||
pub fn credentials_get(name: &str) -> Result<Value, String> {
|
||||
let profile = load_profile(name)?;
|
||||
Ok(json!({
|
||||
"name": profile.name,
|
||||
"username": profile.username,
|
||||
"url": profile.url,
|
||||
"hasPassword": true,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn credentials_get_full(name: &str) -> Result<AuthProfile, String> {
|
||||
load_profile(name)
|
||||
}
|
||||
|
||||
pub fn credentials_delete(name: &str) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let path = get_profile_path(name);
|
||||
if !path.exists() {
|
||||
return Err(format!("Auth profile '{}' not found", name));
|
||||
}
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to delete profile: {}", e))?;
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
|
||||
pub fn credentials_list() -> Result<Value, String> {
|
||||
let dir = get_auth_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "profiles": [] }));
|
||||
}
|
||||
|
||||
let mut profiles = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
match load_profile(&name) {
|
||||
Ok(profile) => {
|
||||
profiles.push(json!({
|
||||
"name": profile.name,
|
||||
"username": profile.username,
|
||||
"url": profile.url,
|
||||
}));
|
||||
}
|
||||
Err(_) => {
|
||||
profiles.push(json!({
|
||||
"name": name,
|
||||
"error": "Failed to decrypt",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "profiles": profiles }))
|
||||
}
|
||||
|
||||
pub fn auth_show(name: &str) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = load_profile(name)?;
|
||||
Ok(json!({
|
||||
"profile": {
|
||||
"name": profile.name,
|
||||
"url": profile.url,
|
||||
"username": profile.username,
|
||||
"usernameSelector": profile.username_selector,
|
||||
"passwordSelector": profile.password_selector,
|
||||
"submitSelector": profile.submit_selector,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static AUTH_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn with_test_key<F: FnOnce()>(f: F) {
|
||||
let _lock = AUTH_TEST_MUTEX.lock().unwrap();
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
let test_key = "a".repeat(64);
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, &test_key) };
|
||||
f();
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
match original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_profile_name() {
|
||||
assert!(validate_profile_name("github").is_ok());
|
||||
assert!(validate_profile_name("my-app").is_ok());
|
||||
assert!(validate_profile_name("test_123").is_ok());
|
||||
assert!(validate_profile_name("").is_err());
|
||||
assert!(validate_profile_name("has space").is_err());
|
||||
assert!(validate_profile_name("../evil").is_err());
|
||||
assert!(validate_profile_name("foo/bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_profile_serialization() {
|
||||
let profile = AuthProfile {
|
||||
name: "test".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: Some("button[type=submit]".to_string()),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let json = serde_json::to_string(&profile).unwrap();
|
||||
let parsed: AuthProfile = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, "test");
|
||||
assert_eq!(
|
||||
parsed.submit_selector,
|
||||
Some("button[type=submit]".to_string())
|
||||
);
|
||||
assert!(parsed.username_selector.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
with_test_key(|| {
|
||||
let profile = AuthProfile {
|
||||
name: "roundtrip".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "s3cret!".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted_json = encrypt_profile(&profile).unwrap();
|
||||
let decrypted = decrypt_profile(encrypted_json.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "roundtrip");
|
||||
assert_eq!(decrypted.password, "s3cret!");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_encryption_key_from_env() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xaa));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_valid() {
|
||||
let hex = "ab".repeat(32);
|
||||
let key = parse_key_hex(&hex).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_invalid() {
|
||||
assert!(parse_key_hex("too_short").is_none());
|
||||
assert!(parse_key_hex(&"g".repeat(64)).is_none());
|
||||
assert!(parse_key_hex("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_json_payload_format() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
let profile = AuthProfile {
|
||||
name: "json-test".to_string(),
|
||||
url: "https://example.com/login".to_string(),
|
||||
username: "admin".to_string(),
|
||||
password: "hunter2".to_string(),
|
||||
username_selector: Some("#email".to_string()),
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
|
||||
// Encrypt with aes_gcm, then manually build the JSON payload
|
||||
// to simulate what Node.js would produce
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
|
||||
let mut iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).unwrap();
|
||||
let plaintext = serde_json::to_string(&profile).unwrap();
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = format!(
|
||||
r#"{{"version":1,"encrypted":true,"iv":"{}","authTag":"{}","data":"{}"}}"#,
|
||||
STANDARD.encode(iv),
|
||||
STANDARD.encode(auth_tag),
|
||||
STANDARD.encode(ciphertext),
|
||||
);
|
||||
|
||||
let decrypted = decrypt_profile(payload.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "json-test");
|
||||
assert_eq!(decrypted.password, "hunter2");
|
||||
assert_eq!(decrypted.username_selector, Some("#email".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_output_is_json_format() {
|
||||
with_test_key(|| {
|
||||
let profile = AuthProfile {
|
||||
name: "format-check".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted = encrypt_profile(&profile).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&encrypted).unwrap();
|
||||
assert_eq!(parsed["version"], 1);
|
||||
assert_eq!(parsed["encrypted"], true);
|
||||
assert!(parsed["iv"].is_string());
|
||||
assert!(parsed["authTag"].is_string());
|
||||
assert!(parsed["data"].is_string());
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,361 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, oneshot, Mutex};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::{CdpCommand, CdpEvent, CdpMessage};
|
||||
|
||||
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
|
||||
|
||||
/// Interval between WebSocket ping frames sent to keep the connection alive
|
||||
/// through intermediate proxies (reverse proxies, load balancers, service meshes).
|
||||
const WS_KEEPALIVE_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
/// Raw incoming CDP message (text) broadcast to all subscribers.
|
||||
/// Used by the inspect proxy to forward responses and events to DevTools.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawCdpMessage {
|
||||
pub text: String,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CdpClient {
|
||||
ws_tx: Arc<
|
||||
Mutex<
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
next_id: AtomicU64,
|
||||
pending: PendingMap,
|
||||
event_tx: broadcast::Sender<CdpEvent>,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
_reader_handle: tokio::task::JoinHandle<()>,
|
||||
_keepalive_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CdpClient {
|
||||
pub async fn connect(url: &str) -> Result<Self, String> {
|
||||
Self::connect_with_headers(url, None).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_headers(
|
||||
url: &str,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<Self, String> {
|
||||
let mut request = url
|
||||
.into_client_request()
|
||||
.map_err(|e| format!("Invalid WebSocket URL: {}", e))?;
|
||||
|
||||
if let Some(hdrs) = headers {
|
||||
let req_headers = request.headers_mut();
|
||||
for (key, value) in hdrs {
|
||||
if let (Ok(name), Ok(val)) = (
|
||||
key.parse::<tokio_tungstenite::tungstenite::http::header::HeaderName>(),
|
||||
value.parse::<tokio_tungstenite::tungstenite::http::header::HeaderValue>(),
|
||||
) {
|
||||
req_headers.insert(name, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ws_config = WebSocketConfig {
|
||||
max_message_size: None,
|
||||
max_frame_size: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (ws_stream, _) =
|
||||
tokio_tungstenite::connect_async_with_config(request, Some(ws_config), false)
|
||||
.await
|
||||
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
|
||||
|
||||
enable_tcp_keepalive(ws_stream.get_ref());
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
let (raw_tx, _) = broadcast::channel(512);
|
||||
|
||||
let pending_clone = pending.clone();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let raw_tx_clone = raw_tx.clone();
|
||||
|
||||
// Notify used to stop the keepalive task when the reader loop exits.
|
||||
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
|
||||
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
// Accept both Text and Binary frames — remote CDP proxies
|
||||
// (e.g. Browserless) may send responses as Binary frames.
|
||||
let msg = match msg {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Binary(data)) => match String::from_utf8(data) {
|
||||
Ok(text) => text,
|
||||
Err(_) => continue,
|
||||
},
|
||||
Ok(Message::Close(frame)) => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let reason = frame
|
||||
.as_ref()
|
||||
.map(|f| format!("code={}, reason={}", f.code, f.reason))
|
||||
.unwrap_or_else(|| "no frame".to_string());
|
||||
let _ =
|
||||
writeln!(std::io::stderr(), "[cdp] WebSocket Close: {}", reason);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(Message::Pong(_)) => continue,
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Error: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Broadcast raw message for inspect proxy subscribers before typed parse,
|
||||
// so messages with negative IDs (used by the inspect proxy) are still delivered.
|
||||
if raw_tx_clone.receiver_count() > 0 {
|
||||
let session_id = serde_json::from_str::<serde_json::Value>(&msg)
|
||||
.ok()
|
||||
.and_then(|v| v.get("sessionId")?.as_str().map(String::from));
|
||||
let _ = raw_tx_clone.send(RawCdpMessage {
|
||||
text: msg.clone(),
|
||||
session_id,
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: CdpMessage = match serde_json::from_str(&msg) {
|
||||
Ok(m) => m,
|
||||
// Expected for inspect proxy messages with negative IDs
|
||||
// (CdpMessage.id is u64); handled via raw broadcast above.
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = parsed.id {
|
||||
// Response to a command
|
||||
let mut pending = pending_clone.lock().await;
|
||||
if let Some(tx) = pending.remove(&id) {
|
||||
let _ = tx.send(parsed);
|
||||
}
|
||||
} else if let Some(ref method) = parsed.method {
|
||||
// Event
|
||||
let event = CdpEvent {
|
||||
method: method.clone(),
|
||||
params: parsed.params.clone().unwrap_or(Value::Null),
|
||||
session_id: parsed.session_id.clone(),
|
||||
};
|
||||
let _ = event_tx_clone.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Reader loop exited (connection closed or error). Drop all pending
|
||||
// command senders so callers get an immediate channel-closed error
|
||||
// instead of waiting for the 30-second timeout.
|
||||
pending_clone.lock().await.clear();
|
||||
|
||||
// Stop the keepalive task — the connection is gone.
|
||||
let _ = cancel_tx.send(true);
|
||||
});
|
||||
|
||||
// Spawn a keepalive task that sends WebSocket Ping frames at a regular
|
||||
// interval. This prevents intermediate proxies (Envoy, nginx, OpenResty,
|
||||
// cloud load balancers) from closing idle WebSocket connections. If the
|
||||
// send fails, the connection is dead and we stop pinging.
|
||||
let keepalive_tx = ws_tx.clone();
|
||||
let keepalive_handle = tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_secs(WS_KEEPALIVE_INTERVAL_SECS);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(interval) => {}
|
||||
_ = cancel_rx.changed() => break,
|
||||
}
|
||||
let mut tx = keepalive_tx.lock().await;
|
||||
if tx.send(Message::Ping(Vec::new())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
ws_tx,
|
||||
next_id: AtomicU64::new(1),
|
||||
pending,
|
||||
event_tx,
|
||||
raw_tx,
|
||||
_reader_handle: reader_handle,
|
||||
_keepalive_handle: keepalive_handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send_command(
|
||||
&self,
|
||||
method: &str,
|
||||
params: Option<Value>,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let cmd = CdpCommand {
|
||||
id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
session_id: session_id.filter(|s| !s.is_empty()).map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&cmd)
|
||||
.map_err(|e| format!("Failed to serialize CDP command: {}", e))?;
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.insert(id, tx);
|
||||
}
|
||||
|
||||
{
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send CDP command: {}", e))?;
|
||||
}
|
||||
|
||||
let response = match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await {
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(_)) => return Err("CDP response channel closed".to_string()),
|
||||
Err(_) => {
|
||||
self.pending.lock().await.remove(&id);
|
||||
return Err(format!("CDP command timed out: {}", method));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(error) = response.error {
|
||||
return Err(format!("CDP error ({}): {}", method, error));
|
||||
}
|
||||
|
||||
Ok(response.result.unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Subscribe to all raw incoming CDP messages (responses + events).
|
||||
/// Used by the inspect proxy to forward traffic to the DevTools frontend.
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Create a lightweight handle for the inspect WebSocket proxy.
|
||||
/// Contains only what's needed to forward messages bidirectionally.
|
||||
pub fn inspect_handle(&self) -> InspectProxyHandle {
|
||||
InspectProxyHandle {
|
||||
ws_tx: self.ws_tx.clone(),
|
||||
raw_tx: self.raw_tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: &P,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<R, String> {
|
||||
let params_value = serde_json::to_value(params)
|
||||
.map_err(|e| format!("Failed to serialize params: {}", e))?;
|
||||
let result = self
|
||||
.send_command(method, Some(params_value), session_id)
|
||||
.await?;
|
||||
serde_json::from_value(result)
|
||||
.map_err(|e| format!("Failed to deserialize CDP response for {}: {}", method, e))
|
||||
}
|
||||
|
||||
pub async fn send_command_no_params(
|
||||
&self,
|
||||
method: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
self.send_command(method, None, session_id).await
|
||||
}
|
||||
|
||||
/// Send raw JSON through the WebSocket without tracking a response.
|
||||
/// Used by the inspect proxy to forward DevTools frontend messages.
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
type WsTx = Arc<
|
||||
Mutex<
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>;
|
||||
|
||||
/// Lightweight handle for the inspect WebSocket proxy, holding only
|
||||
/// the cloneable parts of CdpClient needed for bidirectional message forwarding.
|
||||
pub struct InspectProxyHandle {
|
||||
ws_tx: WsTx,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
}
|
||||
|
||||
impl InspectProxyHandle {
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable TCP SO_KEEPALIVE on the underlying socket of a WebSocket connection.
|
||||
/// This is best-effort: failures are silently ignored since the WebSocket-level
|
||||
/// Ping keepalive provides the primary connection liveness mechanism.
|
||||
fn enable_tcp_keepalive(stream: &tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>) {
|
||||
let tcp_stream = match stream {
|
||||
tokio_tungstenite::MaybeTlsStream::Plain(s) => s,
|
||||
tokio_tungstenite::MaybeTlsStream::Rustls(s) => s.get_ref().0,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// SockRef borrows the fd without taking ownership.
|
||||
let sock = socket2::SockRef::from(tcp_stream);
|
||||
let keepalive = socket2::TcpKeepalive::new().with_time(std::time::Duration::from_secs(30));
|
||||
|
||||
// with_interval sets TCP_KEEPINTVL — the time between probes after the
|
||||
// first keepalive probe goes unanswered. Available on most platforms
|
||||
// (Linux, macOS, Windows, FreeBSD, etc.) but not OpenBSD or Haiku.
|
||||
#[cfg(not(any(target_os = "openbsd", target_os = "haiku")))]
|
||||
let keepalive = keepalive.with_interval(std::time::Duration::from_secs(10));
|
||||
|
||||
let _ = sock.set_tcp_keepalive(&keepalive);
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::BrowserVersionInfo;
|
||||
|
||||
/// Default timeout for CDP discovery HTTP requests.
|
||||
const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Discover the CDP WebSocket URL for the given host and port.
|
||||
///
|
||||
/// Tries three methods in order: `/json/version`, `/json/list`, and a direct
|
||||
/// WebSocket connection to `/devtools/browser`. The returned URL has its
|
||||
/// host/port rewritten to match the requested target.
|
||||
///
|
||||
/// An optional `query` string (without the leading `?`) is appended to the
|
||||
/// final WebSocket URL so that user-supplied URL parameters (e.g.
|
||||
/// `?mode=Hello`) are forwarded to the remote endpoint.
|
||||
pub async fn discover_cdp_url(
|
||||
host: &str,
|
||||
port: u16,
|
||||
query: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
discover_cdp_url_with_timeout(host, port, query, DEFAULT_DISCOVERY_TIMEOUT).await
|
||||
}
|
||||
|
||||
/// Like [`discover_cdp_url`] but with a custom request timeout.
|
||||
pub async fn discover_cdp_url_with_timeout(
|
||||
host: &str,
|
||||
port: u16,
|
||||
query: Option<&str>,
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
// Primary: /json/version (standard path)
|
||||
let version_err = match fetch_cdp_info(host, port, timeout).await {
|
||||
Ok(info) => {
|
||||
if let Some(ws_url) = info.web_socket_debugger_url {
|
||||
return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query));
|
||||
}
|
||||
format!(
|
||||
"No webSocketDebuggerUrl in /json/version at {}:{}",
|
||||
host, port
|
||||
)
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Fallback: /json/list (returns target list; look for the browser target)
|
||||
let list_err = match fetch_cdp_list(host, port, timeout).await {
|
||||
Ok(ws_url) => return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query)),
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Final fallback: direct WebSocket at /devtools/browser.
|
||||
// Chrome 136+ with UI-based remote debugging (chrome://inspect) exposes
|
||||
// CDP over WebSocket but does not serve HTTP discovery endpoints.
|
||||
match discover_cdp_ws(host, port, timeout).await {
|
||||
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
||||
Err(ws_err) => Err(format!(
|
||||
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
|
||||
host, port, version_err, list_err, ws_err
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
|
||||
fn bracket_ipv6(host: &str) -> String {
|
||||
if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{}]", host)
|
||||
} else {
|
||||
host.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `/json/version` from the given host:port and parse the response.
|
||||
async fn fetch_cdp_info(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
) -> Result<BrowserVersionInfo, String> {
|
||||
let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
|
||||
|
||||
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
|
||||
.map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
|
||||
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
|
||||
}
|
||||
|
||||
/// Rewrite the host and port in a WebSocket URL to match the target we
|
||||
/// actually connected to. Chrome's `/json/version` always returns
|
||||
/// `ws://127.0.0.1:<local-port>/...` which is unreachable when the
|
||||
/// browser is on a remote machine or behind a port-forward.
|
||||
fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
|
||||
if let Ok(mut parsed) = url::Url::parse(ws_url) {
|
||||
let _ = parsed.set_host(Some(&bracket_ipv6(host)));
|
||||
let _ = parsed.set_port(Some(port));
|
||||
parsed.to_string()
|
||||
} else {
|
||||
ws_url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a query string to a URL, preserving any existing query parameters.
|
||||
fn append_query(url: &str, query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(q) if !q.is_empty() => {
|
||||
if let Ok(mut parsed) = url::Url::parse(url) {
|
||||
{
|
||||
let mut pairs = parsed.query_pairs_mut();
|
||||
pairs.extend_pairs(url::form_urlencoded::parse(q.as_bytes()));
|
||||
}
|
||||
parsed.to_string()
|
||||
} else {
|
||||
// Fallback: raw string append
|
||||
if url.contains('?') {
|
||||
format!("{}&{}", url, q)
|
||||
} else {
|
||||
format!("{}?{}", url, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `/json/list` and extract the `webSocketDebuggerUrl` from the first
|
||||
/// target with `type == "browser"`, or the first target if none has that type.
|
||||
async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
|
||||
let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
|
||||
|
||||
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to connect to /json/list at {}:{}: {}",
|
||||
host, port, e
|
||||
)
|
||||
})?;
|
||||
|
||||
let targets: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
|
||||
|
||||
// Prefer targets with type "browser", fall back to first target with a ws URL
|
||||
let browser_target = targets
|
||||
.iter()
|
||||
.find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
|
||||
|
||||
let target = browser_target.or_else(|| targets.first());
|
||||
|
||||
target
|
||||
.and_then(|t| t.get("webSocketDebuggerUrl"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
|
||||
}
|
||||
|
||||
/// Discover a CDP endpoint by connecting directly to `ws://host:port/devtools/browser`
|
||||
/// and verifying it responds to `Browser.getVersion`.
|
||||
/// Returns the WebSocket URL on success.
|
||||
async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
|
||||
let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port);
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
let (mut ws_stream, _) = tokio_tungstenite::connect_async(&ws_url)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?;
|
||||
|
||||
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
|
||||
ws_stream
|
||||
.send(Message::Text(cmd.into()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send command: {}", e))?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdpReply {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
let mut result: Result<(), String> = Err("No valid CDP response received".to_string());
|
||||
while let Some(msg) = ws_stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
if serde_json::from_str::<CdpReply>(&text).is_ok_and(|r| r.id == 1) {
|
||||
result = Ok(());
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = ws_stream.close(None).await;
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to WebSocket at {}", ws_url))?
|
||||
.map(|()| ws_url)
|
||||
}
|
||||
|
||||
async fn reqwest_get_string(url: &str) -> Result<String, String> {
|
||||
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
|
||||
resp.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const HTTP_404: &str =
|
||||
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
|
||||
fn http_200(body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
|
||||
body.len(), body
|
||||
)
|
||||
}
|
||||
|
||||
async fn accept_http(listener: &TcpListener, response: &str) {
|
||||
let (mut s, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = s.read(&mut buf).await;
|
||||
s.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovers_ws_url_from_json_version() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_error_when_version_returns_invalid_json() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(&listener, &http_200("not-json")).await;
|
||||
// /json/list and ws fallback both fail (server closes)
|
||||
});
|
||||
|
||||
let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
|
||||
assert!(err.contains("Invalid /json/version response"));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_json_list_on_version_404() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#),
|
||||
).await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert!(ws_url.contains("/devtools/browser/abc"));
|
||||
assert!(ws_url.contains(&port.to_string()));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_ws_when_http_returns_404() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
// /json/version -> 404, /json/list -> 404
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
|
||||
// WebSocket handshake + respond to Browser.getVersion
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
if let Some(Ok(Message::Text(text))) = ws.next().await {
|
||||
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let id = req.get("id").unwrap();
|
||||
let reply = format!(
|
||||
r#"{{"id":{},"result":{{"protocolVersion":"1.3","product":"Chrome/136"}}}}"#,
|
||||
id
|
||||
);
|
||||
ws.send(Message::Text(reply)).await.unwrap();
|
||||
}
|
||||
let _ = ws.close(None).await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/devtools/browser", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_ws_host_replaces_host_and_port() {
|
||||
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
|
||||
assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_ws_host_handles_ipv6() {
|
||||
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let rewritten = rewrite_ws_host(original, "::1", 9222);
|
||||
assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_adds_params_to_url_without_query() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some("mode=Hello"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_merges_with_existing_query() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz";
|
||||
let result = append_query(url, Some("mode=Hello"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?token=xyz&mode=Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_noop_for_none() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, None);
|
||||
assert_eq!(result, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_noop_for_empty() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some(""));
|
||||
assert_eq!(result, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_handles_multiple_params() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some("mode=Hello&token=abc"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello&token=abc"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_preserves_query_params() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, Some("mode=Hello"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/?mode=Hello", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::discovery::discover_cdp_url_with_timeout;
|
||||
|
||||
const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const LIGHTPANDA_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const LIGHTPANDA_SESSION_TIMEOUT_SECS: u64 = 604800; // 1 week, the documented maximum
|
||||
const MAX_LOG_LINES: usize = 40;
|
||||
|
||||
pub struct LightpandaProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
_log_drainers: Vec<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LightpandaProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightpandaProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LightpandaLaunchOptions {
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
fn build_lightpanda_serve_args(port: u16, proxy: Option<&str>) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--timeout".to_string(),
|
||||
LIGHTPANDA_SESSION_TIMEOUT_SECS.to_string(),
|
||||
];
|
||||
|
||||
if let Some(proxy) = proxy {
|
||||
args.push("--http_proxy".to_string());
|
||||
args.push(proxy.to_string());
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct LaunchLogBuffer {
|
||||
stdout: Arc<Mutex<VecDeque<String>>>,
|
||||
stderr: Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl LaunchLogBuffer {
|
||||
fn push_stdout(&self, line: String) {
|
||||
push_bounded(&self.stdout, line);
|
||||
}
|
||||
|
||||
fn push_stderr(&self, line: String) {
|
||||
push_bounded(&self.stderr, line);
|
||||
}
|
||||
|
||||
fn snapshot_stdout(&self) -> Vec<String> {
|
||||
self.stdout
|
||||
.lock()
|
||||
.expect("stdout log buffer poisoned")
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot_stderr(&self) -> Vec<String> {
|
||||
self.stderr
|
||||
.lock()
|
||||
.expect("stderr log buffer poisoned")
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn push_bounded(buffer: &Mutex<VecDeque<String>>, line: String) {
|
||||
let mut guard = buffer.lock().expect("log buffer poisoned");
|
||||
if guard.len() >= MAX_LOG_LINES {
|
||||
guard.pop_front();
|
||||
}
|
||||
guard.push_back(line);
|
||||
}
|
||||
|
||||
pub fn find_lightpanda() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let candidates = [
|
||||
home.join(".lightpanda/lightpanda"),
|
||||
home.join(".local/bin/lightpanda"),
|
||||
];
|
||||
for c in &candidates {
|
||||
if c.exists() {
|
||||
return Some(c.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn launch_lightpanda(
|
||||
options: &LightpandaLaunchOptions,
|
||||
) -> Result<LightpandaProcess, String> {
|
||||
let binary_path = match &options.executable_path {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => find_lightpanda().ok_or(
|
||||
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
|
||||
)?,
|
||||
};
|
||||
|
||||
let port = match options.port {
|
||||
Some(p) => p,
|
||||
None => TcpListener::bind("127.0.0.1:0")
|
||||
.and_then(|l| l.local_addr())
|
||||
.map(|a| a.port())
|
||||
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
|
||||
};
|
||||
let args = build_lightpanda_serve_args(port, options.proxy.as_deref());
|
||||
|
||||
let mut child = Command::new(&binary_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
|
||||
|
||||
let (log_buffer, log_drainers) = start_log_drainers(&mut child)?;
|
||||
|
||||
let ws_url =
|
||||
match wait_for_lightpanda_ready(&mut child, port, &log_buffer, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(LightpandaProcess {
|
||||
child,
|
||||
ws_url,
|
||||
_log_drainers: log_drainers,
|
||||
})
|
||||
}
|
||||
|
||||
fn start_log_drainers(
|
||||
child: &mut Child,
|
||||
) -> Result<(LaunchLogBuffer, Vec<std::thread::JoinHandle<()>>), String> {
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stdout".to_string()
|
||||
})?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stderr".to_string()
|
||||
})?;
|
||||
|
||||
let logs = LaunchLogBuffer::default();
|
||||
let stdout_logs = logs.clone();
|
||||
let stderr_logs = logs.clone();
|
||||
|
||||
let stdout_handle =
|
||||
std::thread::spawn(move || drain_reader(stdout, move |line| stdout_logs.push_stdout(line)));
|
||||
let stderr_handle =
|
||||
std::thread::spawn(move || drain_reader(stderr, move |line| stderr_logs.push_stderr(line)));
|
||||
|
||||
Ok((logs, vec![stdout_handle, stderr_handle]))
|
||||
}
|
||||
|
||||
fn drain_reader<R, F>(reader: R, mut push: F)
|
||||
where
|
||||
R: std::io::Read,
|
||||
F: FnMut(String),
|
||||
{
|
||||
for line in BufReader::new(reader).lines() {
|
||||
match line {
|
||||
Ok(line) => push(line),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_lightpanda_ready(
|
||||
child: &mut Child,
|
||||
port: u16,
|
||||
logs: &LaunchLogBuffer,
|
||||
startup_timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
let deadline = std::time::Instant::now() + startup_timeout;
|
||||
let mut last_probe_error = None;
|
||||
|
||||
loop {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
// Give the drainer threads a brief window to flush the last log lines
|
||||
// before we snapshot them. This is best-effort: lines written just
|
||||
// before exit may still be missing, but the most useful output (early
|
||||
// startup errors) will already be in the buffer.
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
return Err(lightpanda_launch_error(
|
||||
&format!(
|
||||
"Lightpanda exited before CDP became ready (status: {})",
|
||||
status
|
||||
),
|
||||
logs,
|
||||
last_probe_error.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
match discover_cdp_url_with_timeout("127.0.0.1", port, None, LIGHTPANDA_DISCOVERY_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(ws_url) => return Ok(ws_url),
|
||||
Err(err) => last_probe_error = Some(err),
|
||||
}
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(lightpanda_launch_error(
|
||||
&format!(
|
||||
"Timed out after {}ms waiting for Lightpanda CDP endpoint on port {}",
|
||||
startup_timeout.as_millis(),
|
||||
port
|
||||
),
|
||||
logs,
|
||||
last_probe_error.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
tokio::time::sleep(LIGHTPANDA_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn lightpanda_launch_error(
|
||||
message: &str,
|
||||
logs: &LaunchLogBuffer,
|
||||
last_probe_error: Option<&str>,
|
||||
) -> String {
|
||||
let stdout_lines = logs.snapshot_stdout();
|
||||
let stderr_lines = logs.snapshot_stderr();
|
||||
let mut details = Vec::new();
|
||||
|
||||
if let Some(err) = last_probe_error {
|
||||
details.push(format!("Last probe error: {}", err));
|
||||
}
|
||||
|
||||
if !stderr_lines.is_empty() {
|
||||
details.push(format!(
|
||||
"Lightpanda stderr (last {} lines):\n {}",
|
||||
stderr_lines.len(),
|
||||
stderr_lines.join("\n ")
|
||||
));
|
||||
}
|
||||
|
||||
if !stdout_lines.is_empty() {
|
||||
details.push(format!(
|
||||
"Lightpanda stdout (last {} lines):\n {}",
|
||||
stdout_lines.len(),
|
||||
stdout_lines.join("\n ")
|
||||
));
|
||||
}
|
||||
|
||||
if details.is_empty() {
|
||||
format!("{} (no stdout/stderr output from Lightpanda)", message)
|
||||
} else {
|
||||
format!("{}\n{}", message, details.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener as TokioTcpListener;
|
||||
|
||||
fn unused_port() -> u16 {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
|
||||
async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) {
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = socket.read(&mut buf).await;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn waits_for_ready_without_logs() {
|
||||
let port = unused_port();
|
||||
tokio::spawn(serve_json_version_once_after_delay(
|
||||
port,
|
||||
150,
|
||||
r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/"}"#,
|
||||
));
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 5"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let ws_url = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn child_exit_surfaces_logs() {
|
||||
let port = unused_port();
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "echo boom >&2; sleep 0.1; exit 23"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let err = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Lightpanda exited before CDP became ready"));
|
||||
assert!(err.contains("boom"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn timeout_reports_last_probe_error() {
|
||||
let port = unused_port();
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 30"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let timeout = Duration::from_millis(300);
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let err = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
wait_for_lightpanda_ready(&mut child, port, &logs, timeout),
|
||||
)
|
||||
.await
|
||||
.expect("ready wait should return before outer timeout")
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Timed out after 300ms waiting for Lightpanda CDP endpoint"));
|
||||
assert!(
|
||||
err.contains("Failed to connect to CDP") || err.contains("Timeout connecting to CDP")
|
||||
);
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_lightpanda_returns_none_when_missing() {
|
||||
let _ = find_lightpanda();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_no_logs() {
|
||||
let logs = LaunchLogBuffer::default();
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &logs, None);
|
||||
assert!(msg.contains("no stdout/stderr output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_with_lines() {
|
||||
let logs = LaunchLogBuffer::default();
|
||||
logs.push_stdout("stdout line".to_string());
|
||||
logs.push_stderr("stderr line".to_string());
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &logs, Some("connect failed"));
|
||||
assert!(msg.contains("stdout line"));
|
||||
assert!(msg.contains("stderr line"));
|
||||
assert!(msg.contains("Last probe error: connect failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_options() {
|
||||
let opts = LightpandaLaunchOptions::default();
|
||||
assert!(opts.executable_path.is_none());
|
||||
assert!(opts.proxy.is_none());
|
||||
assert!(opts.port.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lightpanda_serve_args_sets_explicit_session_timeout() {
|
||||
let args = build_lightpanda_serve_args(9222, None);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
"9222".to_string(),
|
||||
"--timeout".to_string(),
|
||||
"604800".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lightpanda_serve_args_with_proxy() {
|
||||
let args = build_lightpanda_serve_args(9333, Some("http://127.0.0.1:8080"));
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
"9333".to_string(),
|
||||
"--timeout".to_string(),
|
||||
"604800".to_string(),
|
||||
"--http_proxy".to_string(),
|
||||
"http://127.0.0.1:8080".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod discovery;
|
||||
pub mod lightpanda;
|
||||
pub mod types;
|
||||
@@ -1,586 +0,0 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Deserialize a value that may be either a string or an integer into a String.
|
||||
/// Lightpanda sends numeric nodeIds/childIds in AX tree responses, while Chrome
|
||||
/// sends strings. This accepts both.
|
||||
fn string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
match v {
|
||||
Value::String(s) => Ok(s),
|
||||
Value::Number(n) => Ok(n.to_string()),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"expected string or integer, got {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize an optional Vec where each element may be a string or integer.
|
||||
fn opt_vec_string_or_int<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<Vec<Value>> = Option::deserialize(deserializer)?;
|
||||
match opt {
|
||||
None => Ok(None),
|
||||
Some(vec) => {
|
||||
let mut result = Vec::with_capacity(vec.len());
|
||||
for v in vec {
|
||||
match v {
|
||||
Value::String(s) => result.push(s),
|
||||
Value::Number(n) => result.push(n.to_string()),
|
||||
other => {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"expected string or integer in array, got {}",
|
||||
other
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP message envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CdpCommand {
|
||||
pub id: u64,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CdpMessage {
|
||||
pub id: Option<u64>,
|
||||
pub result: Option<Value>,
|
||||
pub error: Option<CdpError>,
|
||||
pub method: Option<String>,
|
||||
pub params: Option<Value>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CdpError {
|
||||
pub code: Option<i64>,
|
||||
pub message: String,
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CdpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP events (broadcast to subscribers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CdpEvent {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInfo {
|
||||
pub target_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub target_type: String,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub attached: Option<bool>,
|
||||
pub browser_context_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetTargetsResult {
|
||||
pub target_infos: Vec<TargetInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachToTargetParams {
|
||||
pub target_id: String,
|
||||
pub flatten: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachToTargetResult {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetDiscoverTargetsParams {
|
||||
pub discover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetParams {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetResult {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloseTargetParams {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
// Target events
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetCreatedEvent {
|
||||
pub target_info: TargetInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetDestroyedEvent {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInfoChangedEvent {
|
||||
pub target_info: TargetInfo,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNavigateParams {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub referrer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNavigateResult {
|
||||
pub frame_id: String,
|
||||
pub loader_id: Option<String>,
|
||||
pub error_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrameNavigatedEvent {
|
||||
pub frame: FrameInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrameInfo {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
// Page.javascriptDialogOpening
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JavascriptDialogOpeningEvent {
|
||||
pub url: String,
|
||||
pub message: String,
|
||||
#[serde(rename = "type")]
|
||||
pub dialog_type: String,
|
||||
pub default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HandleJavaScriptDialogParams {
|
||||
pub accept: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_text: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluateParams {
|
||||
pub expression: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub return_by_value: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub await_promise: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluateResult {
|
||||
pub result: RemoteObject,
|
||||
pub exception_details: Option<ExceptionDetails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteObject {
|
||||
#[serde(rename = "type")]
|
||||
pub object_type: String,
|
||||
pub subtype: Option<String>,
|
||||
pub value: Option<Value>,
|
||||
pub description: Option<String>,
|
||||
pub object_id: Option<String>,
|
||||
pub class_name: Option<String>,
|
||||
pub unserializable_value: Option<String>,
|
||||
pub preview: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExceptionDetails {
|
||||
pub text: String,
|
||||
pub exception: Option<RemoteObject>,
|
||||
pub line_number: Option<i64>,
|
||||
pub column_number: Option<i64>,
|
||||
}
|
||||
|
||||
// Runtime.consoleAPICalled
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsoleApiCalledEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub args: Vec<RemoteObject>,
|
||||
pub timestamp: Option<f64>,
|
||||
}
|
||||
|
||||
// Runtime.exceptionThrown
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExceptionThrownEvent {
|
||||
pub timestamp: f64,
|
||||
pub exception_details: ExceptionDetails,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetFullAXTreeResult {
|
||||
pub nodes: Vec<AXNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXNode {
|
||||
#[serde(deserialize_with = "string_or_int")]
|
||||
pub node_id: String,
|
||||
pub role: Option<AXValue>,
|
||||
pub name: Option<AXValue>,
|
||||
pub value: Option<AXValue>,
|
||||
pub description: Option<AXValue>,
|
||||
pub properties: Option<Vec<AXProperty>>,
|
||||
#[serde(default, deserialize_with = "opt_vec_string_or_int")]
|
||||
pub child_ids: Option<Vec<String>>,
|
||||
pub backend_d_o_m_node_id: Option<i64>,
|
||||
pub ignored: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXValue {
|
||||
#[serde(rename = "type")]
|
||||
pub value_type: String,
|
||||
pub value: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXProperty {
|
||||
pub name: String,
|
||||
pub value: AXValue,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network domain (minimal for Phase 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestWillBeSentEvent {
|
||||
pub request_id: String,
|
||||
pub request: NetworkRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkRequest {
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadingFinishedEvent {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadingFailedEvent {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomResolveNodeParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend_node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomResolveNodeResult {
|
||||
pub object: RemoteObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetBoxModelParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend_node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetBoxModelResult {
|
||||
pub model: BoxModel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoxModel {
|
||||
pub content: Vec<f64>,
|
||||
pub padding: Vec<f64>,
|
||||
pub border: Vec<f64>,
|
||||
pub margin: Vec<f64>,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomQuerySelectorParams {
|
||||
pub node_id: i64,
|
||||
pub selector: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomQuerySelectorResult {
|
||||
pub node_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetDocumentParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub depth: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetDocumentResult {
|
||||
pub root: DomNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomNode {
|
||||
pub node_id: i64,
|
||||
pub backend_node_id: Option<i64>,
|
||||
pub node_type: Option<i64>,
|
||||
pub node_name: Option<String>,
|
||||
pub children: Option<Vec<DomNode>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DispatchMouseEventParams {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub button: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub buttons: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub click_count: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta_x: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta_y: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DispatchKeyEventParams {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unmodified_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub windows_virtual_key_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub native_virtual_key_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InsertTextParams {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page.captureScreenshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureScreenshotParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<Viewport>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub from_surface: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capture_beyond_viewport: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Viewport {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureScreenshotResult {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime.callFunctionOn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallFunctionOnParams {
|
||||
pub function_declaration: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Vec<CallArgument>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub return_by_value: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub await_promise: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallArgument {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version info (from /json/version)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrowserVersionInfo {
|
||||
#[serde(rename = "webSocketDebuggerUrl")]
|
||||
pub web_socket_debugger_url: Option<String>,
|
||||
#[serde(rename = "Browser")]
|
||||
pub browser: Option<String>,
|
||||
}
|
||||
|
||||
/// Auto-generated CDP types from protocol JSON files in `cdp-protocol/`.
|
||||
///
|
||||
/// To populate: download `browser_protocol.json` and `js_protocol.json` from
|
||||
/// <https://github.com/nicolo-ribaudo/nicolo-ribaudo.github.io/> (or any
|
||||
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
|
||||
///
|
||||
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cookie {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub domain: String,
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub expires: f64,
|
||||
#[serde(default)]
|
||||
pub size: i64,
|
||||
#[serde(default)]
|
||||
pub http_only: bool,
|
||||
#[serde(default)]
|
||||
pub secure: bool,
|
||||
#[serde(default)]
|
||||
pub session: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub same_site: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_all_cookies(client: &CdpClient, session_id: &str) -> Result<Vec<Cookie>, String> {
|
||||
let result = client
|
||||
.send_command_no_params("Network.getAllCookies", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let cookies: Vec<Cookie> = result
|
||||
.get("cookies")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
pub async fn get_cookies(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
urls: Option<Vec<String>>,
|
||||
) -> Result<Vec<Cookie>, String> {
|
||||
let params = match urls {
|
||||
Some(ref u) if !u.is_empty() => json!({ "urls": u }),
|
||||
_ => json!({}),
|
||||
};
|
||||
|
||||
let result = client
|
||||
.send_command("Network.getCookies", Some(params), Some(session_id))
|
||||
.await?;
|
||||
|
||||
let cookies: Vec<Cookie> = result
|
||||
.get("cookies")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
pub async fn set_cookies(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
cookies: Vec<Value>,
|
||||
current_url: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let cookies: Vec<Value> = cookies
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
// Auto-fill url if no domain/path/url provided
|
||||
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
|
||||
c.as_object_mut().map(|m| {
|
||||
m.insert(
|
||||
"url".to_string(),
|
||||
Value::String(current_url.unwrap().to_string()),
|
||||
)
|
||||
});
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Network.setCookies",
|
||||
Some(json!({ "cookies": cookies })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_cookies(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
client
|
||||
.send_command_no_params("Network.clearBrowserCookies", Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,575 +0,0 @@
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::state;
|
||||
use super::stream::StreamServer;
|
||||
|
||||
pub async fn run_daemon(session: &str) {
|
||||
let socket_dir = get_daemon_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
}
|
||||
|
||||
// When debug mode is on, redirect stderr to a log file so daemon
|
||||
// output can be inspected (the daemon normally has stderr piped to its
|
||||
// parent which drops the read end after startup).
|
||||
#[cfg(unix)]
|
||||
if env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let log_path = socket_dir.join(format!("{}.log", session));
|
||||
if let Ok(file) = fs::File::create(&log_path) {
|
||||
use std::os::unix::io::IntoRawFd;
|
||||
let fd = file.into_raw_fd();
|
||||
unsafe {
|
||||
libc::dup2(fd, 2);
|
||||
libc::close(fd);
|
||||
}
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[daemon] Debug logging started for session: {}",
|
||||
session
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
// On Unix the daemon listens on a Unix domain socket; on Windows it uses
|
||||
// TCP, so there is no .sock file — only a .port file written by the server.
|
||||
let socket_path = socket_dir.join(format!("{}.sock", session));
|
||||
|
||||
#[cfg(unix)]
|
||||
if socket_path.exists() {
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
|
||||
}
|
||||
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
|
||||
if let Ok(days) = days_str.parse::<u64>() {
|
||||
if days > 0 {
|
||||
let _ = state::state_clean(days);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
|
||||
let mut stream_server_instance: Option<Arc<StreamServer>> = None;
|
||||
let preferred_port = env::var("AGENT_BROWSER_STREAM_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(0);
|
||||
match StreamServer::start_without_client(preferred_port, session.to_string(), true).await {
|
||||
Ok((stream_server, client_slot)) => {
|
||||
stream_client = Some(client_slot.clone());
|
||||
if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to write .stream file: {}", e);
|
||||
}
|
||||
stream_server_instance = Some(Arc::new(stream_server));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-shutdown the daemon after this many ms of inactivity (no commands received).
|
||||
// Disabled when unset or 0.
|
||||
let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.filter(|&ms| ms > 0);
|
||||
|
||||
let result = run_socket_server(
|
||||
&socket_path,
|
||||
session,
|
||||
stream_client,
|
||||
stream_server_instance,
|
||||
idle_timeout_ms,
|
||||
)
|
||||
.await;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
|
||||
}
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = writeln!(std::io::stderr(), "Daemon error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_socket_server(
|
||||
socket_path: &PathBuf,
|
||||
session: &str,
|
||||
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
stream_server: Option<Arc<StreamServer>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
let listener =
|
||||
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
|
||||
|
||||
let stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||
let dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
Some(dir.join(format!("{}.stream", session)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
||||
);
|
||||
|
||||
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
|
||||
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
|
||||
|
||||
let mut drain_interval = tokio::time::interval(Duration::from_millis(500));
|
||||
drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
||||
let mut sleep_pin = sleep_future.map(Box::pin);
|
||||
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
let reset_tx = reset_tx.clone();
|
||||
let sf = stream_file.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = drain_interval.tick() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
if mgr.has_process_exited() {
|
||||
let _ = mgr.close().await;
|
||||
s.browser = None;
|
||||
s.screencasting = false;
|
||||
s.update_stream_client().await;
|
||||
} else {
|
||||
s.drain_cdp_events_background().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut s) = sleep_pin {
|
||||
s.as_mut().await
|
||||
} else {
|
||||
std::future::pending::<()>().await
|
||||
}
|
||||
}, if idle_timeout_ms.is_some() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
|
||||
continue;
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn run_socket_server(
|
||||
socket_path: &PathBuf,
|
||||
session: &str,
|
||||
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
stream_server: Option<Arc<StreamServer>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let preferred_port = get_port_for_session(session);
|
||||
// Try the hash-derived port first; if it is blocked (e.g. Windows Hyper-V
|
||||
// excluded port range), fall back to an OS-assigned ephemeral port.
|
||||
let listener = match TcpListener::bind(format!("127.0.0.1:{}", preferred_port)).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind TCP: {}", e))?,
|
||||
};
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local address: {}", e))?
|
||||
.port();
|
||||
|
||||
let socket_dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
let port_path = socket_dir.join(format!("{}.port", session));
|
||||
let _ = fs::write(&port_path, actual_port.to_string());
|
||||
|
||||
let stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||
Some(socket_dir.join(format!("{}.stream", session)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)),
|
||||
);
|
||||
|
||||
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
|
||||
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
|
||||
|
||||
loop {
|
||||
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
||||
let mut sleep_pin = sleep_future.map(Box::pin);
|
||||
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
let reset_tx = reset_tx.clone();
|
||||
let sf = stream_file.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut s) = sleep_pin {
|
||||
s.as_mut().await
|
||||
} else {
|
||||
std::future::pending::<()>().await
|
||||
}
|
||||
}, if idle_timeout_ms.is_some() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
let _ = fs::remove_file(&port_path);
|
||||
break;
|
||||
}
|
||||
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
|
||||
continue;
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
let _ = fs::remove_file(&port_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection<S>(
|
||||
stream: S,
|
||||
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
||||
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
|
||||
stream_file_cleanup: Option<PathBuf>,
|
||||
) where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
let mut buf_reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match buf_reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if looks_like_http(trimmed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let cmd: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("Invalid JSON: {}", e),
|
||||
});
|
||||
let mut resp = serde_json::to_string(&err).unwrap_or_default();
|
||||
resp.push('\n');
|
||||
let _ = writer.write_all(resp.as_bytes()).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref tx) = idle_reset_tx {
|
||||
let _ = tx.try_send(());
|
||||
}
|
||||
|
||||
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
|
||||
|
||||
let response = {
|
||||
let mut s = state.lock().await;
|
||||
execute_command(&cmd, &mut s).await
|
||||
};
|
||||
|
||||
let mut resp = serde_json::to_string(&response).unwrap_or_default();
|
||||
resp.push('\n');
|
||||
if writer.write_all(resp.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
if is_close {
|
||||
if let Some(ref path) = stream_file_cleanup {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_http(line: &str) -> bool {
|
||||
let prefixes = [
|
||||
"GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "CONNECT ", "TRACE ",
|
||||
];
|
||||
prefixes.iter().any(|p| line.starts_with(p))
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut sigint = match signal::unix::signal(signal::unix::SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to install SIGINT handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sigterm = match signal::unix::signal(signal::unix::SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"Failed to install SIGTERM handler: {}",
|
||||
e
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sighup = match signal::unix::signal(signal::unix::SignalKind::hangup()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to install SIGHUP handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {}
|
||||
_ = sigterm.recv() => {}
|
||||
_ = sighup.recv() => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Err(e) = signal::ctrl_c().await {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to install Ctrl+C handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_daemon_socket_dir() -> PathBuf {
|
||||
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("agent-browser");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".agent-browser");
|
||||
}
|
||||
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_for_session(session: &str) -> u16 {
|
||||
let mut hash: i32 = 0;
|
||||
for c in session.chars() {
|
||||
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
|
||||
}
|
||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_port_matches_client_algorithm() {
|
||||
assert_eq!(get_port_for_session("default"), 50838);
|
||||
assert_eq!(get_port_for_session("my-session"), 63105);
|
||||
assert_eq!(get_port_for_session("work"), 51184);
|
||||
assert_eq!(get_port_for_session(""), 49152);
|
||||
}
|
||||
|
||||
/// Guard against re-introducing `waitpid(-1)` in daemon code.
|
||||
///
|
||||
/// Issue #1035: a SIGCHLD handler that called `waitpid(-1, WNOHANG)` was
|
||||
/// added in v0.22.3 to reap zombie Chrome processes. This races with
|
||||
/// Rust's `Child::try_wait()` / `Child::wait()` because `waitpid(-1)`
|
||||
/// reaps *any* child, stealing the exit status before Rust can collect
|
||||
/// it. The result is ECHILD errors in `BrowserManager::has_process_exited()`
|
||||
/// and `ChromeProcess::kill()`, which can leave the daemon in a broken
|
||||
/// state or cause hangs on certain Linux configurations.
|
||||
///
|
||||
/// The fix uses the existing 500ms drain interval to call
|
||||
/// `has_process_exited()` (which delegates to `Child::try_wait()`)
|
||||
/// for targeted, race-free zombie detection.
|
||||
#[test]
|
||||
fn test_no_waitpid_minus_one_in_daemon() {
|
||||
let source = include_str!("daemon.rs");
|
||||
// Only check production code (everything before `#[cfg(test)]`)
|
||||
let production_code = source.split("#[cfg(test)]").next().unwrap_or(source);
|
||||
assert!(
|
||||
!production_code.contains("waitpid(-1"),
|
||||
"daemon.rs production code must not call waitpid(-1, ...). \
|
||||
Use Child::try_wait() via has_process_exited() instead. \
|
||||
See issue #1035."
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that `Child::try_wait()` correctly detects a crashed child
|
||||
/// without needing a global SIGCHLD handler or `waitpid(-1)`.
|
||||
/// This is what `has_process_exited()` uses in the fixed code.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_child_try_wait_detects_exit_without_sigchld_handler() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "exit 42"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child");
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
assert!(
|
||||
!status.success(),
|
||||
"child exited with code 42, should not be success"
|
||||
);
|
||||
}
|
||||
Ok(None) => panic!("try_wait() returned None but child should have exited"),
|
||||
Err(e) => panic!("try_wait() should succeed without waitpid(-1): {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `ChromeProcess::has_exited()` (which uses `Child::try_wait()`)
|
||||
/// correctly detects a killed child, the same way the drain interval does
|
||||
/// in the fixed daemon code. This ensures crash detection works without
|
||||
/// a SIGCHLD handler.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_has_exited_detects_killed_process() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 60"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child");
|
||||
|
||||
// Process should be running
|
||||
match child.try_wait() {
|
||||
Ok(None) => {} // expected
|
||||
other => panic!("expected Ok(None) for running process, got {:?}", other),
|
||||
}
|
||||
|
||||
// Kill it (simulates Chrome crash)
|
||||
child.kill().expect("failed to kill child");
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
// try_wait should detect the exit
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {} // expected: detected the crash
|
||||
other => panic!(
|
||||
"expected Ok(Some(_)) after kill, got {:?}. \
|
||||
Crash detection via try_wait() must work for the drain \
|
||||
interval fix (issue #1035) to function correctly.",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
pub struct ScreenshotDiffResult {
|
||||
pub total_pixels: u64,
|
||||
pub different_pixels: u64,
|
||||
pub mismatch_percentage: f64,
|
||||
pub matched: bool,
|
||||
pub diff_image: Option<Vec<u8>>,
|
||||
pub dimension_mismatch: Option<Value>,
|
||||
}
|
||||
|
||||
pub struct SnapshotDiffResult {
|
||||
pub diff: String,
|
||||
pub additions: usize,
|
||||
pub removals: usize,
|
||||
pub unchanged: usize,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
pub fn diff_screenshot(
|
||||
baseline: &[u8],
|
||||
current: &[u8],
|
||||
threshold: f64,
|
||||
) -> Result<ScreenshotDiffResult, String> {
|
||||
let img_a = image::load_from_memory(baseline)
|
||||
.map_err(|e| format!("Failed to decode baseline image: {}", e))?;
|
||||
let img_b = image::load_from_memory(current)
|
||||
.map_err(|e| format!("Failed to decode current image: {}", e))?;
|
||||
|
||||
let (wa, ha) = (img_a.width(), img_a.height());
|
||||
let (wb, hb) = (img_b.width(), img_b.height());
|
||||
|
||||
if wa != wb || ha != hb {
|
||||
return Ok(ScreenshotDiffResult {
|
||||
total_pixels: (wa as u64) * (ha as u64),
|
||||
different_pixels: (wa as u64) * (ha as u64),
|
||||
mismatch_percentage: 100.0,
|
||||
matched: false,
|
||||
diff_image: None,
|
||||
dimension_mismatch: Some(json!({
|
||||
"expected": { "width": wa, "height": ha },
|
||||
"actual": { "width": wb, "height": hb },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
let rgba_a = img_a.to_rgba8();
|
||||
let rgba_b = img_b.to_rgba8();
|
||||
let total = (wa as u64) * (ha as u64);
|
||||
let max_color_distance = threshold * 255.0 * (3.0_f64).sqrt();
|
||||
let mut different = 0u64;
|
||||
|
||||
let mut diff_img = image::RgbaImage::new(wa, ha);
|
||||
|
||||
for y in 0..ha {
|
||||
for x in 0..wa {
|
||||
let pa = rgba_a.get_pixel(x, y);
|
||||
let pb = rgba_b.get_pixel(x, y);
|
||||
let dr = (pa[0] as f64) - (pb[0] as f64);
|
||||
let dg = (pa[1] as f64) - (pb[1] as f64);
|
||||
let db = (pa[2] as f64) - (pb[2] as f64);
|
||||
let dist = (dr * dr + dg * dg + db * db).sqrt();
|
||||
|
||||
if dist > max_color_distance {
|
||||
different += 1;
|
||||
diff_img.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
|
||||
} else {
|
||||
let gray = ((pa[0] as u16 + pa[1] as u16 + pa[2] as u16) / 3) as u8;
|
||||
let dimmed = (gray as f64 * 0.3) as u8;
|
||||
diff_img.put_pixel(x, y, image::Rgba([dimmed, dimmed, dimmed, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mismatch = if total > 0 {
|
||||
(different as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let diff_bytes = if different > 0 {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
diff_img
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode diff image: {}", e))?;
|
||||
Some(buf.into_inner())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ScreenshotDiffResult {
|
||||
total_pixels: total,
|
||||
different_pixels: different,
|
||||
mismatch_percentage: mismatch,
|
||||
matched: different == 0,
|
||||
diff_image: diff_bytes,
|
||||
dimension_mismatch: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute a snapshot diff using the Myers algorithm via the `similar` crate.
|
||||
pub fn diff_snapshots(before: &str, after: &str) -> SnapshotDiffResult {
|
||||
// Fast path: identical inputs.
|
||||
// This avoids constructing the `similar` TextDiff object and running the diff
|
||||
// iteration when agents compare a snapshot to itself (common in retry/loop
|
||||
// workloads).
|
||||
if before == after {
|
||||
let unchanged = before.lines().count();
|
||||
return SnapshotDiffResult {
|
||||
diff: String::new(),
|
||||
additions: 0,
|
||||
removals: 0,
|
||||
unchanged,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
let text_diff = TextDiff::from_lines(before, after);
|
||||
|
||||
let mut additions = 0usize;
|
||||
let mut removals = 0usize;
|
||||
let mut unchanged = 0usize;
|
||||
|
||||
for change in text_diff.iter_all_changes() {
|
||||
match change.tag() {
|
||||
ChangeTag::Insert => additions += 1,
|
||||
ChangeTag::Delete => removals += 1,
|
||||
ChangeTag::Equal => unchanged += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let changed = additions > 0 || removals > 0;
|
||||
|
||||
let diff = text_diff
|
||||
.unified_diff()
|
||||
.context_radius(3)
|
||||
.header("before", "after")
|
||||
.to_string();
|
||||
|
||||
SnapshotDiffResult {
|
||||
diff,
|
||||
additions,
|
||||
removals,
|
||||
unchanged,
|
||||
changed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy JSON diff output for backwards compatibility.
|
||||
pub fn diff_text(a: &str, b: &str) -> Value {
|
||||
let result = diff_snapshots(a, b);
|
||||
json!({
|
||||
"identical": !result.changed,
|
||||
"additions": result.additions,
|
||||
"removals": result.removals,
|
||||
"deletions": result.removals,
|
||||
"unchanged": result.unchanged,
|
||||
"changed": result.changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn diff_unified(a: &str, b: &str) -> String {
|
||||
diff_snapshots(a, b).diff
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_diff_identical() {
|
||||
let result = diff_text("hello\nworld", "hello\nworld");
|
||||
assert_eq!(result.get("identical").unwrap(), true);
|
||||
assert_eq!(result.get("changed").unwrap(), false);
|
||||
assert_eq!(result.get("unchanged").unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_additions() {
|
||||
let result = diff_text("hello\n", "hello\nworld\n");
|
||||
assert_eq!(result.get("identical").unwrap(), false);
|
||||
assert_eq!(result.get("changed").unwrap(), true);
|
||||
assert!(result.get("additions").unwrap().as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_deletions() {
|
||||
let result = diff_text("hello\nworld\n", "hello\n");
|
||||
assert_eq!(result.get("identical").unwrap(), false);
|
||||
assert!(result.get("removals").unwrap().as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_unified_output() {
|
||||
let output = diff_unified("a\nb\nc\n", "a\nx\nc\n");
|
||||
assert!(output.contains("---"));
|
||||
assert!(output.contains("+++"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_diff_struct() {
|
||||
let result = diff_snapshots("line1\nline2\n", "line1\nline3\n");
|
||||
assert!(result.changed);
|
||||
assert_eq!(result.additions, 1);
|
||||
assert_eq!(result.removals, 1);
|
||||
assert_eq!(result.unchanged, 1);
|
||||
assert!(!result.diff.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_snapshots_identical_fast_path() {
|
||||
let input = "hello\nworld\n";
|
||||
let result = diff_snapshots(input, input);
|
||||
assert!(!result.changed);
|
||||
assert_eq!(result.additions, 0);
|
||||
assert_eq!(result.removals, 0);
|
||||
assert_eq!(result.unchanged, input.lines().count());
|
||||
assert!(result.diff.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_diff_snapshots_identical_and_changed() {
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
let identical_a = (0..200)
|
||||
.map(|i| format!("line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let identical_b = identical_a.clone();
|
||||
|
||||
let changed_a = identical_a.clone();
|
||||
let changed_b = (0..200)
|
||||
.map(|i| {
|
||||
if i == 123 {
|
||||
format!("line {i} changed")
|
||||
} else {
|
||||
format!("line {i}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
// Keep the iteration count high enough to measure, but low enough
|
||||
// to avoid long CI times when someone runs `--ignored`.
|
||||
let iters = 50_000usize;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut acc_changed = 0usize;
|
||||
for _ in 0..iters {
|
||||
let r = diff_snapshots(black_box(&identical_a), black_box(&identical_b));
|
||||
acc_changed ^= r.unchanged;
|
||||
}
|
||||
let identical_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut acc_changed2 = 0usize;
|
||||
for _ in 0..iters {
|
||||
let r = diff_snapshots(black_box(&changed_a), black_box(&changed_b));
|
||||
acc_changed2 ^= r.additions;
|
||||
}
|
||||
let changed_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// Prevent the compiler from optimizing everything away.
|
||||
black_box(acc_changed);
|
||||
black_box(acc_changed2);
|
||||
|
||||
println!(
|
||||
"bench_diff_snapshots_identical_and_changed: iters={iters} identical_ms={identical_ms:.2} changed_ms={changed_ms:.2}"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,362 +0,0 @@
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::cdp::client::InspectProxyHandle;
|
||||
|
||||
/// Counter for unique attach IDs so concurrent connections don't collide.
|
||||
static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000);
|
||||
|
||||
/// Lightweight HTTP + WebSocket server for `agent-browser inspect`.
|
||||
///
|
||||
/// Serves two purposes:
|
||||
/// - `GET /` redirects to Chrome's built-in DevTools frontend with `ws=` pointing to this server
|
||||
/// - WebSocket connections create a dedicated CDP session via `Target.attachToTarget` and proxy
|
||||
/// CDP messages through the daemon's existing browser-level connection, injecting/stripping
|
||||
/// `sessionId` so the DevTools frontend sees a page-level view
|
||||
pub struct InspectServer {
|
||||
port: u16,
|
||||
_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl InspectServer {
|
||||
/// Start the inspect proxy server.
|
||||
///
|
||||
/// - `proxy_handle`: lightweight handle for sending/receiving raw CDP messages
|
||||
/// - `target_id`: the CDP target ID of the page to inspect
|
||||
/// - `chrome_host_port`: the Chrome debug server address (e.g. "127.0.0.1:9222")
|
||||
pub async fn start(
|
||||
proxy_handle: InspectProxyHandle,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
) -> Result<Self, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind inspect server: {}", e))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local addr: {}", e))?
|
||||
.port();
|
||||
|
||||
let proxy = Arc::new(proxy_handle);
|
||||
|
||||
let handle = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
proxy,
|
||||
target_id,
|
||||
chrome_host_port,
|
||||
port,
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
_handle: handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
self._handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) {
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let proxy = proxy.clone();
|
||||
let tid = target_id.clone();
|
||||
let chp = chrome_host_port.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, proxy, tid, chp, proxy_port).await {
|
||||
let _ = writeln!(std::io::stderr(), "[inspect] connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
// Peek at the request line to determine routing WITHOUT consuming bytes.
|
||||
// This is critical: tokio_tungstenite::accept_async needs to read the full
|
||||
// HTTP upgrade request itself, so we must not consume anything for WS paths.
|
||||
let mut peek_buf = [0u8; 32];
|
||||
let n = stream
|
||||
.peek(&mut peek_buf)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let peek = String::from_utf8_lossy(&peek_buf[..n]);
|
||||
|
||||
if peek.starts_with("GET /ws") {
|
||||
return handle_ws_proxy(stream, proxy, target_id).await;
|
||||
}
|
||||
|
||||
if peek.starts_with("GET / ") {
|
||||
let buf_reader = BufReader::new(stream);
|
||||
return handle_http_redirect(buf_reader, chrome_host_port, proxy_port).await;
|
||||
}
|
||||
|
||||
// Unknown request -- consume and respond 404
|
||||
let mut stream = stream;
|
||||
let mut discard = [0u8; 4096];
|
||||
let _ = stream.read(&mut discard).await;
|
||||
let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MAX_HEADER_BYTES: usize = 8192;
|
||||
|
||||
async fn handle_http_redirect(
|
||||
buf_reader: BufReader<tokio::net::TcpStream>,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
let mut br = buf_reader;
|
||||
let mut total_bytes = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = br.read_line(&mut line).await.map_err(|e| e.to_string())?;
|
||||
total_bytes += n;
|
||||
if line == "\r\n" || line == "\n" || line.is_empty() || total_bytes > MAX_HEADER_BYTES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let location = format!(
|
||||
"http://{}/devtools/devtools_app.html?ws=127.0.0.1:{}/ws",
|
||||
chrome_host_port, proxy_port
|
||||
);
|
||||
let body = format!(
|
||||
"<html><body>Redirecting to <a href=\"{url}\">{url}</a></body></html>",
|
||||
url = location
|
||||
);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
location,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let mut stream = br.into_inner();
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_ws_proxy(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
) -> Result<(), String> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket handshake failed: {}", e))?;
|
||||
|
||||
// Create a dedicated CDP session for this DevTools connection.
|
||||
// Each connection gets its own session so domain enablements (DOM.enable, etc.)
|
||||
// always trigger fresh initial state dumps from Chrome.
|
||||
let attach_id = ATTACH_ID.fetch_sub(1, Ordering::SeqCst);
|
||||
let attach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.attachToTarget","params":{{"targetId":"{}","flatten":true}}}}"#,
|
||||
attach_id, target_id
|
||||
);
|
||||
|
||||
// Subscribe BEFORE sending so we don't miss the response (tokio broadcast
|
||||
// receivers only deliver messages to receivers that already exist).
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
|
||||
proxy
|
||||
.send_raw(attach_cmd)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send attachToTarget: {}", e))?;
|
||||
|
||||
// Wait for the attachToTarget response to extract the session ID
|
||||
let session_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while let Ok(raw_msg) = raw_rx.recv().await {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw_msg.text) {
|
||||
if val.get("id").and_then(|v| v.as_i64()) == Some(attach_id) {
|
||||
if let Some(sid) = val
|
||||
.get("result")
|
||||
.and_then(|r| r.get("sessionId"))
|
||||
.and_then(|s| s.as_str())
|
||||
{
|
||||
return Ok(sid.to_string());
|
||||
}
|
||||
return Err("attachToTarget failed".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("raw message channel closed".to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Timed out waiting for attachToTarget response".to_string())?
|
||||
.map_err(|e| format!("Failed to create DevTools session: {}", e))?;
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
let ws_tx_clone = ws_tx.clone();
|
||||
let session_id_clone = session_id.clone();
|
||||
|
||||
// Chrome -> DevTools: forward messages matching our session, strip sessionId
|
||||
let mut chrome_to_devtools = tokio::spawn(async move {
|
||||
loop {
|
||||
let raw_msg = match raw_rx.recv().await {
|
||||
Ok(msg) => msg,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[inspect] warning: dropped {} CDP messages (channel lag)",
|
||||
n
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if raw_msg.session_id.as_deref() != Some(&session_id_clone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stripped = strip_session_id(&raw_msg.text);
|
||||
|
||||
let mut tx = ws_tx_clone.lock().await;
|
||||
if tx.send(Message::Text(stripped)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DevTools -> Chrome: inject sessionId and forward
|
||||
let proxy_for_send = proxy.clone();
|
||||
let session_id_for_send = session_id.clone();
|
||||
let mut devtools_to_chrome = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let injected = inject_session_id(&text, &session_id_for_send);
|
||||
if proxy_for_send.send_raw(injected).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut chrome_to_devtools => {
|
||||
devtools_to_chrome.abort();
|
||||
},
|
||||
_ = &mut devtools_to_chrome => {
|
||||
chrome_to_devtools.abort();
|
||||
},
|
||||
}
|
||||
|
||||
// Clean up the CDP session so Chrome doesn't leak attached targets
|
||||
let detach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.detachFromTarget","params":{{"sessionId":"{}"}}}}"#,
|
||||
ATTACH_ID.fetch_sub(1, Ordering::SeqCst),
|
||||
session_id
|
||||
);
|
||||
let _ = proxy.send_raw(detach_cmd).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inject_session_id(json: &str, session_id: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert(
|
||||
"sessionId".to_string(),
|
||||
serde_json::Value::String(session_id.to_string()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_session_id(json: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.remove("sessionId");
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id() {
|
||||
let input = r#"{"id":1,"method":"DOM.getDocument"}"#;
|
||||
let result = inject_session_id(input, "abc123");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc123");
|
||||
assert_eq!(parsed["method"], "DOM.getDocument");
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id_empty_object() {
|
||||
let result = inject_session_id("{}", "abc");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_session_id() {
|
||||
let input = r#"{"id":1,"result":{},"sessionId":"abc123"}"#;
|
||||
let result = strip_session_id(input);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert!(parsed.get("sessionId").is_none());
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_then_strip_roundtrip() {
|
||||
let input = r#"{"id":42,"method":"Runtime.evaluate"}"#;
|
||||
let injected = inject_session_id(input, "sess1");
|
||||
let stripped = strip_session_id(&injected);
|
||||
let original: serde_json::Value = serde_json::from_str(input).unwrap();
|
||||
let result: serde_json::Value = serde_json::from_str(&stripped).unwrap();
|
||||
assert_eq!(original, result);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
#[allow(dead_code)]
|
||||
pub mod actions;
|
||||
#[allow(dead_code)]
|
||||
pub mod auth;
|
||||
#[allow(dead_code)]
|
||||
pub mod browser;
|
||||
#[allow(dead_code)]
|
||||
pub mod cdp;
|
||||
#[allow(dead_code)]
|
||||
pub mod cookies;
|
||||
#[allow(dead_code)]
|
||||
pub mod daemon;
|
||||
#[allow(dead_code)]
|
||||
pub mod diff;
|
||||
#[allow(dead_code)]
|
||||
pub mod element;
|
||||
#[allow(dead_code)]
|
||||
pub mod inspect_server;
|
||||
#[allow(dead_code)]
|
||||
pub mod interaction;
|
||||
#[allow(dead_code)]
|
||||
pub mod network;
|
||||
#[allow(dead_code)]
|
||||
pub mod policy;
|
||||
#[allow(dead_code)]
|
||||
pub mod providers;
|
||||
#[allow(dead_code)]
|
||||
pub mod recording;
|
||||
#[allow(dead_code)]
|
||||
pub mod screenshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod snapshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod state;
|
||||
#[allow(dead_code)]
|
||||
pub mod stealth;
|
||||
#[allow(dead_code)]
|
||||
pub mod storage;
|
||||
#[allow(dead_code)]
|
||||
pub mod stream;
|
||||
#[allow(dead_code)]
|
||||
pub mod tracing;
|
||||
#[allow(dead_code)]
|
||||
pub mod webdriver;
|
||||
|
||||
#[cfg(test)]
|
||||
mod e2e_tests;
|
||||
#[cfg(test)]
|
||||
mod parity_tests;
|
||||
@@ -1,672 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
pub async fn set_extra_headers(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let headers_value: Value = headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect::<serde_json::Map<String, Value>>()
|
||||
.into();
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Network.setExtraHTTPHeaders",
|
||||
Some(json!({ "headers": headers_value })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_offline(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
offline: bool,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Network.emulateNetworkConditions",
|
||||
Some(json!({
|
||||
"offline": offline,
|
||||
"latency": 0,
|
||||
"downloadThroughput": -1,
|
||||
"uploadThroughput": -1,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_content(client: &CdpClient, session_id: &str, html: &str) -> Result<(), String> {
|
||||
// Get current frame ID
|
||||
let tree_result = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let frame_id = tree_result
|
||||
.get("frameTree")
|
||||
.and_then(|t| t.get("frame"))
|
||||
.and_then(|f| f.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.ok_or("Could not determine frame ID")?;
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Page.setDocumentContent",
|
||||
Some(json!({
|
||||
"frameId": frame_id,
|
||||
"html": html,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DomainFilter {
|
||||
pub allowed_domains: Vec<String>,
|
||||
}
|
||||
|
||||
impl DomainFilter {
|
||||
pub fn new(domains: &str) -> Self {
|
||||
let allowed = parse_domain_list(domains);
|
||||
Self {
|
||||
allowed_domains: allowed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_allowed(&self, hostname: &str) -> bool {
|
||||
if self.allowed_domains.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let hostname = hostname.to_lowercase();
|
||||
for pattern in &self.allowed_domains {
|
||||
if let Some(suffix) = pattern.strip_prefix("*.") {
|
||||
if hostname == suffix || hostname.ends_with(&format!(".{}", suffix)) {
|
||||
return true;
|
||||
}
|
||||
} else if hostname == *pattern {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn check_url(&self, url: &str) -> Result<(), String> {
|
||||
if self.allowed_domains.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parsed = url::Url::parse(url).map_err(|_| format!("Invalid URL: {}", url))?;
|
||||
let hostname = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| format!("No hostname in URL: {}", url))?;
|
||||
if self.is_allowed(hostname) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Domain '{}' is not in the allowed domains list",
|
||||
hostname
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_domain_list(input: &str) -> Vec<String> {
|
||||
input
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn sanitize_existing_pages(
|
||||
client: &CdpClient,
|
||||
pages: &[super::browser::PageInfo],
|
||||
filter: &DomainFilter,
|
||||
) {
|
||||
for page in pages {
|
||||
if page.url.is_empty() || page.url == "about:blank" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(parsed) = url::Url::parse(&page.url) {
|
||||
if let Some(hostname) = parsed.host_str() {
|
||||
if !filter.is_allowed(hostname) {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": "about:blank" })),
|
||||
Some(&page.session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_domain_filter_script(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
allowed_domains: &[String],
|
||||
) -> Result<(), String> {
|
||||
if allowed_domains.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let domains_json = serde_json::to_string(allowed_domains).unwrap_or("[]".to_string());
|
||||
let script = format!(
|
||||
r#"(() => {{
|
||||
const _allowed = {};
|
||||
function _isDomainAllowed(hostname) {{
|
||||
hostname = hostname.toLowerCase();
|
||||
for (const p of _allowed) {{
|
||||
if (p.startsWith('*.')) {{
|
||||
const suffix = p.slice(2);
|
||||
if (hostname === suffix || hostname.endsWith('.' + suffix)) return true;
|
||||
}} else if (hostname === p) return true;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
const OrigWS = window.WebSocket;
|
||||
window.WebSocket = function(url, protocols) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) throw new DOMException('WebSocket blocked: ' + u.hostname, 'SecurityError');
|
||||
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
|
||||
return new OrigWS(url, protocols);
|
||||
}};
|
||||
window.WebSocket.prototype = OrigWS.prototype;
|
||||
const OrigES = window.EventSource;
|
||||
if (OrigES) {{
|
||||
window.EventSource = function(url, opts) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) throw new DOMException('EventSource blocked: ' + u.hostname, 'SecurityError');
|
||||
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
|
||||
return new OrigES(url, opts);
|
||||
}};
|
||||
window.EventSource.prototype = OrigES.prototype;
|
||||
}}
|
||||
const origBeacon = navigator.sendBeacon;
|
||||
if (origBeacon) {{
|
||||
navigator.sendBeacon = function(url, data) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) return false;
|
||||
}} catch(e) {{ return false; }}
|
||||
return origBeacon.call(navigator, url, data);
|
||||
}};
|
||||
}}
|
||||
}})()"#,
|
||||
domains_json,
|
||||
);
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "source": script })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable Fetch-based network interception for domain filtering.
|
||||
/// This intercepts all requests and checks them against the allowed domains list.
|
||||
/// The actual handling of `Fetch.requestPaused` events happens in
|
||||
/// `resolve_fetch_paused` in the actions module.
|
||||
pub async fn install_domain_filter_fetch(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
handle_auth_requests: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut params = json!({
|
||||
"patterns": [{ "urlPattern": "*" }]
|
||||
});
|
||||
if handle_auth_requests {
|
||||
params["handleAuthRequests"] = json!(true);
|
||||
}
|
||||
client
|
||||
.send_command("Fetch.enable", Some(params), Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install both layers of domain filtering on a session:
|
||||
/// 1. JS patching (WebSocket, EventSource, sendBeacon)
|
||||
/// 2. Fetch-based network interception
|
||||
pub async fn install_domain_filter(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
allowed_domains: &[String],
|
||||
handle_auth_requests: bool,
|
||||
) -> Result<(), String> {
|
||||
install_domain_filter_script(client, session_id, allowed_domains).await?;
|
||||
install_domain_filter_fetch(client, session_id, handle_auth_requests).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console arg formatting (CDP RemoteObject → human-readable string)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a single CDP RemoteObject arg into a human-readable string.
|
||||
/// Priority: value → preview → description.
|
||||
pub fn format_console_arg(arg: &Value) -> Option<String> {
|
||||
let obj_type = arg.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let subtype = arg.get("subtype").and_then(|v| v.as_str());
|
||||
|
||||
if obj_type == "undefined" {
|
||||
return Some("undefined".to_string());
|
||||
}
|
||||
|
||||
if subtype == Some("null") {
|
||||
return Some("null".to_string());
|
||||
}
|
||||
|
||||
// Primitive value
|
||||
if let Some(v) = arg.get("value") {
|
||||
return Some(match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => "null".to_string(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Skip preview for Map/Set — their description ("Map(1)", "Set(3)") is more useful
|
||||
// than their preview properties (which only show "size")
|
||||
if let Some(preview) = arg.get("preview") {
|
||||
let preview_subtype = preview.get("subtype").and_then(|v| v.as_str());
|
||||
if matches!(preview_subtype, Some("map" | "set" | "weakmap" | "weakset")) {
|
||||
return arg
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
let is_array = subtype == Some("array") || preview_subtype == Some("array");
|
||||
if let Some(props) = preview.get("properties").and_then(|v| v.as_array()) {
|
||||
let overflow = preview
|
||||
.get("overflow")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let formatted_props: Vec<String> = props
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let value_str = p.get("value").and_then(|v| v.as_str())?;
|
||||
let prop_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let formatted_value = if prop_type == "string" {
|
||||
format!("\"{}\"", value_str)
|
||||
} else {
|
||||
value_str.to_string()
|
||||
};
|
||||
if is_array {
|
||||
Some(formatted_value)
|
||||
} else {
|
||||
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
Some(format!("{}: {}", name, formatted_value))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let inner = if overflow {
|
||||
format!("{}, ...", formatted_props.join(", "))
|
||||
} else {
|
||||
formatted_props.join(", ")
|
||||
};
|
||||
|
||||
return if is_array {
|
||||
Some(format!("[{}]", inner))
|
||||
} else {
|
||||
Some(format!("{{{}}}", inner))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to description
|
||||
arg.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Format an array of CDP RemoteObject args into a single space-separated string.
|
||||
pub fn format_console_args(args: &[Value]) -> String {
|
||||
args.iter()
|
||||
.filter_map(format_console_arg)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console and error tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConsoleEntry {
|
||||
pub level: String,
|
||||
pub text: String,
|
||||
pub args: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErrorEntry {
|
||||
pub text: String,
|
||||
pub url: Option<String>,
|
||||
pub line: Option<i64>,
|
||||
pub column: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct EventTracker {
|
||||
pub console_entries: Vec<ConsoleEntry>,
|
||||
pub error_entries: Vec<ErrorEntry>,
|
||||
pub max_entries: usize,
|
||||
}
|
||||
|
||||
impl EventTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
console_entries: Vec::new(),
|
||||
error_entries: Vec::new(),
|
||||
max_entries: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_console(&mut self, level: &str, text: &str, args: Vec<Value>) {
|
||||
if self.console_entries.len() >= self.max_entries {
|
||||
self.console_entries.remove(0);
|
||||
}
|
||||
self.console_entries.push(ConsoleEntry {
|
||||
level: level.to_string(),
|
||||
text: text.to_string(),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_error(
|
||||
&mut self,
|
||||
text: &str,
|
||||
url: Option<&str>,
|
||||
line: Option<i64>,
|
||||
col: Option<i64>,
|
||||
) {
|
||||
if self.error_entries.len() >= self.max_entries {
|
||||
self.error_entries.remove(0);
|
||||
}
|
||||
self.error_entries.push(ErrorEntry {
|
||||
text: text.to_string(),
|
||||
url: url.map(String::from),
|
||||
line,
|
||||
column: col,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn clear_console(&mut self) {
|
||||
self.console_entries.clear();
|
||||
}
|
||||
|
||||
pub fn get_console_json(&self) -> Value {
|
||||
let messages: Vec<Value> = self
|
||||
.console_entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut msg = json!({ "type": e.level, "text": e.text });
|
||||
if !e.args.is_empty() {
|
||||
msg.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("args".to_string(), Value::Array(e.args.clone()));
|
||||
}
|
||||
msg
|
||||
})
|
||||
.collect();
|
||||
json!({ "messages": messages })
|
||||
}
|
||||
|
||||
pub fn get_errors_json(&self) -> Value {
|
||||
let entries: Vec<Value> = self
|
||||
.error_entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
json!({
|
||||
"text": e.text,
|
||||
"url": e.url,
|
||||
"line": e.line,
|
||||
"column": e.column,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "errors": entries })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_exact() {
|
||||
let filter = DomainFilter::new("example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_wildcard() {
|
||||
let filter = DomainFilter::new("*.example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(filter.is_allowed("api.example.com"));
|
||||
assert!(filter.is_allowed("sub.api.example.com"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_empty() {
|
||||
let filter = DomainFilter::new("");
|
||||
assert!(filter.is_allowed("anything.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_multiple() {
|
||||
let filter = DomainFilter::new("example.com, *.api.io");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(filter.is_allowed("api.io"));
|
||||
assert!(filter.is_allowed("v1.api.io"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_domain_list() {
|
||||
let domains = parse_domain_list("A.com, B.com , *.C.com");
|
||||
assert_eq!(domains, vec!["a.com", "b.com", "*.c.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_tracker() {
|
||||
let mut tracker = EventTracker::new();
|
||||
tracker.add_console("log", "hello", vec![]);
|
||||
tracker.add_error("oops", Some("test.js"), Some(1), Some(5));
|
||||
|
||||
assert_eq!(tracker.console_entries.len(), 1);
|
||||
assert_eq!(tracker.error_entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_json_includes_args() {
|
||||
let mut tracker = EventTracker::new();
|
||||
let raw_args = vec![
|
||||
json!({"type": "string", "value": "hello"}),
|
||||
json!({"type": "number", "value": 42}),
|
||||
];
|
||||
tracker.add_console("log", "hello 42", raw_args);
|
||||
|
||||
let result = tracker.get_console_json();
|
||||
let messages = result.get("messages").unwrap().as_array().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].get("text").unwrap(), "hello 42");
|
||||
let args = messages[0].get("args").unwrap().as_array().unwrap();
|
||||
assert_eq!(args.len(), 2);
|
||||
assert_eq!(args[0], json!({"type": "string", "value": "hello"}));
|
||||
assert_eq!(args[1], json!({"type": "number", "value": 42}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_json_empty_args_omits_field() {
|
||||
let mut tracker = EventTracker::new();
|
||||
tracker.add_console("log", "text only", vec![]);
|
||||
|
||||
let result = tracker.get_console_json();
|
||||
let messages = result.get("messages").unwrap().as_array().unwrap();
|
||||
assert!(messages[0].get("args").is_none());
|
||||
}
|
||||
|
||||
// -- format_console_arg: primitives --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_string() {
|
||||
let arg = json!({"type": "string", "value": "hello"});
|
||||
assert_eq!(format_console_arg(&arg), Some("hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_number() {
|
||||
let arg = json!({"type": "number", "value": 42});
|
||||
assert_eq!(format_console_arg(&arg), Some("42".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_null() {
|
||||
let arg = json!({"type": "object", "subtype": "null", "value": null});
|
||||
assert_eq!(format_console_arg(&arg), Some("null".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_undefined() {
|
||||
let arg = json!({"type": "undefined"});
|
||||
assert_eq!(format_console_arg(&arg), Some("undefined".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: objects with preview --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_object_preview() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [
|
||||
{"name": "userId", "type": "string", "value": "abc123"},
|
||||
{"name": "count", "type": "number", "value": "42"}
|
||||
],
|
||||
"overflow": false
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
format_console_arg(&arg),
|
||||
Some("{userId: \"abc123\", count: 42}".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_object_preview_overflow() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [
|
||||
{"name": "a", "type": "number", "value": "1"}
|
||||
],
|
||||
"overflow": true
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("{a: 1, ...}".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: arrays with preview --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_array_preview() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"subtype": "array",
|
||||
"preview": {
|
||||
"subtype": "array",
|
||||
"properties": [
|
||||
{"name": "0", "type": "number", "value": "1"},
|
||||
{"name": "1", "type": "number", "value": "2"},
|
||||
{"name": "2", "type": "number", "value": "3"}
|
||||
],
|
||||
"overflow": false
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("[1, 2, 3]".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: map/set use description --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_map_uses_description() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"subtype": "map",
|
||||
"description": "Map(1)",
|
||||
"preview": {
|
||||
"subtype": "map",
|
||||
"properties": [{"name": "size", "type": "number", "value": "1"}]
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("Map(1)".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: fallback --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_description_fallback() {
|
||||
let arg = json!({"type": "object", "description": "RegExp"});
|
||||
assert_eq!(format_console_arg(&arg), Some("RegExp".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_no_value_no_preview_no_description() {
|
||||
let arg = json!({"type": "object"});
|
||||
assert_eq!(format_console_arg(&arg), None);
|
||||
}
|
||||
|
||||
// -- format_console_args --
|
||||
|
||||
#[test]
|
||||
fn test_format_console_args_join() {
|
||||
let args = vec![
|
||||
json!({"type": "string", "value": "user"}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [{"name": "id", "type": "number", "value": "1"}],
|
||||
"overflow": false
|
||||
}
|
||||
}),
|
||||
];
|
||||
assert_eq!(format_console_args(&args), "user {id: 1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_console_args_filters_none() {
|
||||
// An arg that returns None should be skipped, not produce empty string
|
||||
let args = vec![
|
||||
json!({"type": "string", "value": "before"}),
|
||||
json!({"type": "object"}), // no value, preview, or description → None
|
||||
json!({"type": "string", "value": "after"}),
|
||||
];
|
||||
assert_eq!(format_console_args(&args), "before after");
|
||||
}
|
||||
}
|
||||
@@ -1,700 +0,0 @@
|
||||
//! Parity tests for the native daemon's command interface.
|
||||
//!
|
||||
//! These unit tests verify:
|
||||
//! - All documented actions are handled (not returning "Not yet implemented")
|
||||
//! - Response format consistency (success/error structure)
|
||||
//! - Credential and state actions work without a browser
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
|
||||
struct TestKeyGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl TestKeyGuard {
|
||||
fn new() -> Self {
|
||||
let lock = super::auth::AUTH_TEST_MUTEX
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
|
||||
Self {
|
||||
_lock: lock,
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestKeyGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
|
||||
match &self.original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All documented action names that should be implemented.
|
||||
const DOCUMENTED_ACTIONS: &[&str] = &[
|
||||
"launch",
|
||||
"navigate",
|
||||
"url",
|
||||
"title",
|
||||
"content",
|
||||
"evaluate",
|
||||
"close",
|
||||
"snapshot",
|
||||
"screenshot",
|
||||
"click",
|
||||
"dblclick",
|
||||
"fill",
|
||||
"type",
|
||||
"press",
|
||||
"hover",
|
||||
"scroll",
|
||||
"select",
|
||||
"check",
|
||||
"uncheck",
|
||||
"wait",
|
||||
"gettext",
|
||||
"getattribute",
|
||||
"isvisible",
|
||||
"isenabled",
|
||||
"ischecked",
|
||||
"back",
|
||||
"forward",
|
||||
"reload",
|
||||
"cookies_get",
|
||||
"cookies_set",
|
||||
"cookies_clear",
|
||||
"storage_get",
|
||||
"storage_set",
|
||||
"storage_clear",
|
||||
"setcontent",
|
||||
"headers",
|
||||
"offline",
|
||||
"console",
|
||||
"errors",
|
||||
"state_save",
|
||||
"state_load",
|
||||
"state_list",
|
||||
"state_show",
|
||||
"state_clear",
|
||||
"state_clean",
|
||||
"state_rename",
|
||||
"trace_start",
|
||||
"trace_stop",
|
||||
"profiler_start",
|
||||
"profiler_stop",
|
||||
"recording_start",
|
||||
"recording_stop",
|
||||
"recording_restart",
|
||||
"pdf",
|
||||
"tab_list",
|
||||
"tab_new",
|
||||
"tab_switch",
|
||||
"tab_close",
|
||||
"viewport",
|
||||
"user_agent",
|
||||
"set_media",
|
||||
"download",
|
||||
"diff_snapshot",
|
||||
"diff_url",
|
||||
"credentials_set",
|
||||
"credentials_get",
|
||||
"credentials_delete",
|
||||
"credentials_list",
|
||||
"mouse",
|
||||
"keyboard",
|
||||
"focus",
|
||||
"clear",
|
||||
"selectall",
|
||||
"scrollintoview",
|
||||
"dispatch",
|
||||
"highlight",
|
||||
"tap",
|
||||
"boundingbox",
|
||||
"innertext",
|
||||
"innerhtml",
|
||||
"inputvalue",
|
||||
"setvalue",
|
||||
"count",
|
||||
"styles",
|
||||
"bringtofront",
|
||||
"timezone",
|
||||
"locale",
|
||||
"geolocation",
|
||||
"permissions",
|
||||
"dialog",
|
||||
"upload",
|
||||
"addscript",
|
||||
"addinitscript",
|
||||
"addstyle",
|
||||
"clipboard",
|
||||
"wheel",
|
||||
"device",
|
||||
"screencast_start",
|
||||
"screencast_stop",
|
||||
"waitforurl",
|
||||
"waitforloadstate",
|
||||
"waitforfunction",
|
||||
"frame",
|
||||
"mainframe",
|
||||
"getbyrole",
|
||||
"getbytext",
|
||||
"getbylabel",
|
||||
"getbyplaceholder",
|
||||
"getbyalttext",
|
||||
"getbytitle",
|
||||
"getbytestid",
|
||||
"nth",
|
||||
"find",
|
||||
"evalhandle",
|
||||
"drag",
|
||||
"expose",
|
||||
"pause",
|
||||
"multiselect",
|
||||
"responsebody",
|
||||
"waitfordownload",
|
||||
"window_new",
|
||||
"diff_screenshot",
|
||||
"video_start",
|
||||
"video_stop",
|
||||
"har_start",
|
||||
"har_stop",
|
||||
"route",
|
||||
"unroute",
|
||||
"requests",
|
||||
"request_detail",
|
||||
"credentials",
|
||||
"auth_save",
|
||||
"auth_login",
|
||||
"auth_list",
|
||||
"auth_delete",
|
||||
"auth_show",
|
||||
"confirm",
|
||||
"deny",
|
||||
"swipe",
|
||||
"device_list",
|
||||
"input_mouse",
|
||||
"input_keyboard",
|
||||
"input_touch",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"inserttext",
|
||||
"mousemove",
|
||||
"mousedown",
|
||||
"mouseup",
|
||||
];
|
||||
|
||||
fn minimal_command(action: &str, id: &str) -> Value {
|
||||
let mut cmd = json!({ "action": action, "id": id });
|
||||
let obj = cmd.as_object_mut().unwrap();
|
||||
|
||||
match action {
|
||||
"navigate" | "diff_url" | "waitforurl" => {
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"evaluate" | "expose" => {
|
||||
obj.insert("script".to_string(), json!("1"));
|
||||
}
|
||||
"click" | "dblclick" | "fill" | "type" | "press" | "hover" | "scroll" | "select"
|
||||
| "check" | "uncheck" | "gettext" | "getattribute" | "isvisible" | "isenabled"
|
||||
| "ischecked" | "focus" | "clear" | "selectall" | "scrollintoview" | "dispatch"
|
||||
| "highlight" | "tap" | "boundingbox" | "innertext" | "innerhtml" | "inputvalue"
|
||||
| "setvalue" | "count" | "find" | "nth" | "getbytext" | "getbylabel"
|
||||
| "getbyplaceholder" | "getbyalttext" | "getbytitle" | "getbytestid" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"getbyrole" => {
|
||||
obj.insert("role".to_string(), json!("button"));
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"setcontent" => {
|
||||
obj.insert("html".to_string(), json!("<html></html>"));
|
||||
}
|
||||
"cookies_set" => {
|
||||
obj.insert("name".to_string(), json!("test"));
|
||||
obj.insert("value".to_string(), json!("val"));
|
||||
}
|
||||
"storage_get" | "storage_set" | "storage_clear" => {
|
||||
obj.insert("origin".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"state_save" | "state_load" | "state_show" | "state_clear" => {
|
||||
obj.insert("path".to_string(), json!("test-parity-state.json"));
|
||||
}
|
||||
"state_rename" => {
|
||||
obj.insert("path".to_string(), json!("test-parity-state.json"));
|
||||
obj.insert("name".to_string(), json!("renamed"));
|
||||
}
|
||||
"state_clean" => {
|
||||
obj.insert("days".to_string(), json!(7));
|
||||
}
|
||||
"credentials_set" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"auth_save" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"credentials_get" | "credentials_delete" | "auth_show" | "auth_delete" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
}
|
||||
"tab_switch" | "tab_close" => {
|
||||
obj.insert("index".to_string(), json!(0));
|
||||
}
|
||||
"viewport" | "user_agent" | "set_media" | "timezone" | "locale" | "geolocation"
|
||||
| "permissions" | "device" => {
|
||||
obj.insert("value".to_string(), json!(null));
|
||||
}
|
||||
"headers" => {
|
||||
obj.insert("headers".to_string(), json!({}));
|
||||
}
|
||||
"offline" => {
|
||||
obj.insert("offline".to_string(), json!(false));
|
||||
}
|
||||
"wait" => {
|
||||
obj.insert("timeout".to_string(), json!(100));
|
||||
}
|
||||
"waitforloadstate" => {
|
||||
obj.insert("state".to_string(), json!("load"));
|
||||
}
|
||||
"waitforfunction" => {
|
||||
obj.insert("script".to_string(), json!("() => true"));
|
||||
}
|
||||
"frame" => {
|
||||
obj.insert("selector".to_string(), json!("iframe"));
|
||||
}
|
||||
"addscript" => {
|
||||
obj.insert("content".to_string(), json!("console.log('test')"));
|
||||
}
|
||||
"addinitscript" => {
|
||||
obj.insert("script".to_string(), json!("console.log('init')"));
|
||||
}
|
||||
"addstyle" => {
|
||||
obj.insert("content".to_string(), json!("body { color: red }"));
|
||||
}
|
||||
"wheel" => {
|
||||
obj.insert("deltaX".to_string(), json!(0));
|
||||
obj.insert("deltaY".to_string(), json!(0));
|
||||
}
|
||||
"upload" => {
|
||||
obj.insert("selector".to_string(), json!("input[type=file]"));
|
||||
obj.insert("files".to_string(), json!([]));
|
||||
}
|
||||
"dialog" => {
|
||||
obj.insert("accept".to_string(), json!(true));
|
||||
}
|
||||
"credentials" => {
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"auth_login" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
}
|
||||
"route" => {
|
||||
obj.insert("url".to_string(), json!("*"));
|
||||
obj.insert("handler".to_string(), json!("continue"));
|
||||
}
|
||||
"diff_snapshot" | "diff_screenshot" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"recording_start" | "recording_restart" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-recording.webm"));
|
||||
}
|
||||
"video_start" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-video.webm"));
|
||||
}
|
||||
"profiler_start" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-profile"));
|
||||
}
|
||||
"trace_stop" | "har_stop" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-trace"));
|
||||
}
|
||||
"download" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-download"));
|
||||
}
|
||||
"multiselect" => {
|
||||
obj.insert("selector".to_string(), json!("select"));
|
||||
obj.insert("values".to_string(), json!([]));
|
||||
}
|
||||
"responsebody" => {
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"waitfordownload" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-download"));
|
||||
}
|
||||
"styles" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("names".to_string(), json!([]));
|
||||
}
|
||||
"evalhandle" => {
|
||||
obj.insert("handle".to_string(), json!(""));
|
||||
obj.insert("script".to_string(), json!("h => h"));
|
||||
}
|
||||
"drag" => {
|
||||
obj.insert("source".to_string(), json!("body"));
|
||||
obj.insert("target".to_string(), json!("body"));
|
||||
}
|
||||
"swipe" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("direction".to_string(), json!("left"));
|
||||
}
|
||||
"input_mouse" | "mousemove" | "mousedown" | "mouseup" => {
|
||||
obj.insert("x".to_string(), json!(100));
|
||||
obj.insert("y".to_string(), json!(100));
|
||||
}
|
||||
"input_keyboard" | "keydown" | "keyup" => {
|
||||
obj.insert("key".to_string(), json!("a"));
|
||||
}
|
||||
"input_touch" => {
|
||||
obj.insert("type".to_string(), json!("touchStart"));
|
||||
obj.insert("touchPoints".to_string(), json!([]));
|
||||
}
|
||||
"inserttext" => {
|
||||
obj.insert("text".to_string(), json!("test"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Action dispatch coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_documented_actions_are_handled() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
|
||||
let id = format!("parity-{}", i);
|
||||
let cmd = minimal_command(action, &id);
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert!(
|
||||
result.get("id").is_some(),
|
||||
"Action '{}': response missing 'id'",
|
||||
action
|
||||
);
|
||||
|
||||
let error = result.get("error").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
assert!(
|
||||
!error.contains("Not yet implemented"),
|
||||
"Action '{}' returned 'Not yet implemented')",
|
||||
action
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response format consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_success_response_format() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_list", "id": "fmt-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result.get("id").is_some());
|
||||
assert!(result.get("data").is_some());
|
||||
assert!(result.get("error").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_response_format() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "nonexistent_action_xyz", "id": "fmt-2" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], false);
|
||||
assert!(result.get("id").is_some());
|
||||
assert!(result.get("error").is_some());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Credential/state actions work without a browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_list_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_list", "id": "nb-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result["data"]["files"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_list_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "credentials_list", "id": "nb-2" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result["data"]["credentials"].is_array() || result["data"]["profiles"].is_array());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. New feature parity tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_profile_name_validation() {
|
||||
use super::auth;
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let valid = auth::credentials_set("valid-name_123", "u", "p", None);
|
||||
assert!(valid.is_ok());
|
||||
let invalid = auth::credentials_set("invalid/name", "u", "p", None);
|
||||
assert!(invalid.is_err());
|
||||
let invalid2 = auth::credentials_set("", "u", "p", None);
|
||||
assert!(invalid2.is_err());
|
||||
let invalid3 = auth::credentials_set("has space", "u", "p", None);
|
||||
assert!(invalid3.is_err());
|
||||
// Cleanup
|
||||
let _ = auth::credentials_delete("valid-name_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_save_and_show() {
|
||||
use super::auth;
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let result = auth::auth_save(
|
||||
"parity-roundtrip",
|
||||
"https://example.com",
|
||||
"user",
|
||||
"pass",
|
||||
Some("input#user"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let show = auth::auth_show("parity-roundtrip");
|
||||
assert!(show.is_ok());
|
||||
let data = show.unwrap();
|
||||
assert_eq!(data["profile"]["username"], "user");
|
||||
assert_eq!(data["profile"]["usernameSelector"], "input#user");
|
||||
|
||||
let full = auth::credentials_get_full("parity-roundtrip");
|
||||
assert!(full.is_ok());
|
||||
assert_eq!(full.unwrap().password, "pass");
|
||||
|
||||
// Cleanup
|
||||
let _ = auth::credentials_delete("parity-roundtrip");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_har_start_stop_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
// har_start requires a browser. Because execute_command auto-launches when
|
||||
// no browser is present, the result depends on Chrome availability: success
|
||||
// if Chrome is found (CI), failure if not. Both outcomes are valid.
|
||||
let cmd = json!({ "action": "har_start", "id": "har-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
let success = result["success"].as_bool().unwrap_or(false);
|
||||
if success {
|
||||
assert!(state.har_recording);
|
||||
} else {
|
||||
assert!(result["error"].as_str().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_clean_action() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_clean", "id": "clean-1", "days": 30 });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
assert_eq!(result["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_daemon_state_new_defaults() {
|
||||
let state = DaemonState::new();
|
||||
assert!(state.browser.is_none());
|
||||
assert!(!state.har_recording);
|
||||
assert!(state.har_entries.is_empty());
|
||||
assert!(state.pending_confirmation.is_none());
|
||||
assert!(!state.request_tracking);
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
assert!(state.active_frame_id.is_none());
|
||||
assert!(state.webdriver_backend.is_none());
|
||||
assert!(state.stream_client.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tracked_request_struct() {
|
||||
use super::actions::TrackedRequest;
|
||||
let tr = TrackedRequest {
|
||||
url: "https://example.com/api".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: json!({"Accept": "text/html"}),
|
||||
timestamp: 12345,
|
||||
resource_type: "Document".to_string(),
|
||||
request_id: "1.1".to_string(),
|
||||
post_data: None,
|
||||
status: Some(200),
|
||||
response_headers: None,
|
||||
mime_type: Some("text/html".to_string()),
|
||||
};
|
||||
let serialized = serde_json::to_value(&tr).unwrap();
|
||||
assert_eq!(serialized["url"], "https://example.com/api");
|
||||
assert_eq!(serialized["method"], "GET");
|
||||
assert_eq!(serialized["resourceType"], "Document");
|
||||
assert_eq!(serialized["timestamp"], 12345);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_tracking_state() {
|
||||
let mut state = DaemonState::new();
|
||||
assert!(!state.request_tracking);
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
|
||||
state.tracked_requests.push(super::actions::TrackedRequest {
|
||||
url: "https://example.com".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: json!({}),
|
||||
timestamp: 1,
|
||||
resource_type: "Document".to_string(),
|
||||
request_id: "1.1".to_string(),
|
||||
post_data: None,
|
||||
status: None,
|
||||
response_headers: None,
|
||||
mime_type: None,
|
||||
});
|
||||
state.tracked_requests.push(super::actions::TrackedRequest {
|
||||
url: "https://other.com".to_string(),
|
||||
method: "POST".to_string(),
|
||||
headers: json!({}),
|
||||
timestamp: 2,
|
||||
resource_type: "XHR".to_string(),
|
||||
request_id: "1.2".to_string(),
|
||||
post_data: None,
|
||||
status: None,
|
||||
response_headers: None,
|
||||
mime_type: None,
|
||||
});
|
||||
assert_eq!(state.tracked_requests.len(), 2);
|
||||
|
||||
// Filter
|
||||
let filtered: Vec<_> = state
|
||||
.tracked_requests
|
||||
.iter()
|
||||
.filter(|r| r.url.contains("example"))
|
||||
.collect();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].url, "https://example.com");
|
||||
|
||||
// Clear
|
||||
state.tracked_requests.clear();
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_status_filter() {
|
||||
use super::actions::matches_status_filter;
|
||||
|
||||
// Exact match
|
||||
assert!(matches_status_filter(Some(200), "200"));
|
||||
assert!(!matches_status_filter(Some(201), "200"));
|
||||
|
||||
// Class match (Nxx)
|
||||
assert!(matches_status_filter(Some(200), "2xx"));
|
||||
assert!(matches_status_filter(Some(299), "2xx"));
|
||||
assert!(!matches_status_filter(Some(301), "2xx"));
|
||||
assert!(matches_status_filter(Some(404), "4xx"));
|
||||
|
||||
// Range match
|
||||
assert!(matches_status_filter(Some(400), "400-499"));
|
||||
assert!(matches_status_filter(Some(499), "400-499"));
|
||||
assert!(!matches_status_filter(Some(500), "400-499"));
|
||||
|
||||
// None status
|
||||
assert!(!matches_status_filter(None, "200"));
|
||||
assert!(!matches_status_filter(None, "2xx"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_addscript_and_addinitscript_separate_dispatch() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
// Both should be handled (not "Not yet implemented") even without a browser
|
||||
let cmd1 = json!({ "action": "addscript", "id": "as-1", "content": "console.log(1)" });
|
||||
let result1 = execute_command(&cmd1, &mut state).await;
|
||||
let err1 = result1["error"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!err1.contains("Not yet implemented"),
|
||||
"addscript should be handled"
|
||||
);
|
||||
|
||||
let cmd2 = json!({ "action": "addinitscript", "id": "ais-1", "script": "console.log(2)" });
|
||||
let result2 = execute_command(&cmd2, &mut state).await;
|
||||
let err2 = result2["error"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!err2.contains("Not yet implemented"),
|
||||
"addinitscript should be handled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_frame_context_management() {
|
||||
let mut state = DaemonState::new();
|
||||
assert!(state.active_frame_id.is_none());
|
||||
|
||||
// Set a frame ID and verify it persists
|
||||
state.active_frame_id = Some("child-frame-123".to_string());
|
||||
assert_eq!(state.active_frame_id.as_deref(), Some("child-frame-123"));
|
||||
|
||||
// Clearing the frame ID (what mainframe does)
|
||||
state.active_frame_id = None;
|
||||
assert!(state.active_frame_id.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_addstyle_supports_content_and_url() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
// Both content-based and url-based addstyle should be recognized
|
||||
let cmd1 = json!({ "action": "addstyle", "id": "style-1", "content": "body { color: red }" });
|
||||
let result1 = execute_command(&cmd1, &mut state).await;
|
||||
let err1 = result1["error"].as_str().unwrap_or("");
|
||||
assert!(!err1.contains("Not yet implemented"));
|
||||
|
||||
let cmd2 =
|
||||
json!({ "action": "addstyle", "id": "style-2", "url": "https://example.com/style.css" });
|
||||
let result2 = execute_command(&cmd2, &mut state).await;
|
||||
let err2 = result2["error"].as_str().unwrap_or("");
|
||||
assert!(!err2.contains("Not yet implemented"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_domain_filter_sanitize() {
|
||||
use super::network::DomainFilter;
|
||||
let filter = DomainFilter::new("example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(!filter.is_allowed("evil.com"));
|
||||
filter.check_url("https://example.com/path").unwrap();
|
||||
assert!(filter.check_url("https://evil.com").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_find_auto_returns_none_for_nonexistent() {
|
||||
use super::state;
|
||||
let result = state::find_auto_state_file("nonexistent-session-xyz");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Result of a policy check for an action.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PolicyResult {
|
||||
/// Action is allowed.
|
||||
Allow,
|
||||
/// Action is blocked with the given reason.
|
||||
Deny(String),
|
||||
/// Action requires confirmation before proceeding.
|
||||
RequiresConfirmation,
|
||||
}
|
||||
|
||||
/// Policy configuration loaded from a JSON file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionPolicy {
|
||||
#[serde(skip)]
|
||||
path: PathBuf,
|
||||
#[serde(default)]
|
||||
default: Option<String>,
|
||||
#[serde(default)]
|
||||
allow: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
deny: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
confirm: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Confirmation categories parsed from AGENT_BROWSER_CONFIRM_ACTIONS.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfirmActions {
|
||||
pub categories: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ConfirmActions {
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let val = env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()?;
|
||||
if val.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let categories: HashSet<String> = val
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if categories.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self { categories })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_confirmation(&self, action: &str) -> bool {
|
||||
self.categories.contains(action)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionPolicy {
|
||||
/// Load policy from a JSON file at the given path.
|
||||
pub fn load(path: &str) -> Result<Self, String> {
|
||||
let path_buf = PathBuf::from(path);
|
||||
let contents = fs::read_to_string(&path_buf)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = path_buf;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// Load policy if AGENT_BROWSER_ACTION_POLICY env var is set.
|
||||
/// Falls back to AGENT_BROWSER_POLICY for backwards compatibility.
|
||||
pub fn load_if_exists() -> Option<Self> {
|
||||
let path = env::var("AGENT_BROWSER_ACTION_POLICY")
|
||||
.or_else(|_| env::var("AGENT_BROWSER_POLICY"))
|
||||
.ok()?;
|
||||
Self::load(&path).ok()
|
||||
}
|
||||
|
||||
/// Check whether an action is allowed, denied, or requires confirmation.
|
||||
pub fn check(&self, action: &str) -> PolicyResult {
|
||||
if let Some(deny) = &self.deny {
|
||||
if deny.iter().any(|a| a == action) {
|
||||
return PolicyResult::Deny(format!("Action '{}' is denied by policy", action));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(confirm) = &self.confirm {
|
||||
if confirm.iter().any(|a| a == action) {
|
||||
return PolicyResult::RequiresConfirmation;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(allow) = &self.allow {
|
||||
if !allow.is_empty() && !allow.iter().any(|a| a == action) {
|
||||
let is_default_deny = self
|
||||
.default
|
||||
.as_deref()
|
||||
.map(|d| d.eq_ignore_ascii_case("deny"))
|
||||
.unwrap_or(true);
|
||||
if is_default_deny {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' is not in the allow list",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(ref default) = self.default {
|
||||
if default.eq_ignore_ascii_case("deny") {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' denied: default policy is deny",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
PolicyResult::Allow
|
||||
}
|
||||
|
||||
/// Reload policy from the file. Re-reads the JSON and updates the policy.
|
||||
pub fn reload(&mut self) -> Result<(), String> {
|
||||
let contents = fs::read_to_string(&self.path)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = self.path.clone();
|
||||
*self = policy;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn test_policy_allow_whitelist() {
|
||||
let json = r#"{"allow": ["click", "type"], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert_eq!(policy.check("type"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny() {
|
||||
let json = r#"{"allow": [], "deny": ["delete"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("delete"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny_takes_precedence() {
|
||||
let json = r#"{"allow": ["danger"], "deny": ["danger"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("danger"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm_takes_precedence_over_allow() {
|
||||
let json = r#"{"allow": ["submit"], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_empty_allow_allows_all() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_missing_allow_allows_all() {
|
||||
let json = r#"{"deny": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_allow() {
|
||||
let json = r#"{"default": "allow", "deny": ["navigate"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_deny() {
|
||||
let json = r#"{"default": "deny", "allow": ["click"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confirm_actions_from_env() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_CONFIRM_ACTIONS"]);
|
||||
_guard.set("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
|
||||
let ca = ConfirmActions::from_env().unwrap();
|
||||
assert!(ca.requires_confirmation("navigate"));
|
||||
assert!(ca.requires_confirmation("click"));
|
||||
assert!(ca.requires_confirmation("fill"));
|
||||
assert!(!ca.requires_confirmation("screenshot"));
|
||||
}
|
||||
}
|
||||
@@ -1,816 +0,0 @@
|
||||
//! Browser provider connections for remote CDP sessions.
|
||||
//!
|
||||
//! Supports AgentCore, Browserbase, Browserless, Browser Use, and Kernel providers.
|
||||
//! Each provider returns a CDP WebSocket URL for connecting via BrowserManager.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
|
||||
/// Provider session info for cleanup on failure.
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderSession {
|
||||
pub provider: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderConnection {
|
||||
pub ws_url: String,
|
||||
pub session: Option<ProviderSession>,
|
||||
/// If true, the WebSocket IS the page session (no Target.* commands).
|
||||
pub direct_page: bool,
|
||||
}
|
||||
|
||||
/// Connects to the specified browser provider and returns a CDP WebSocket URL
|
||||
/// along with session info for cleanup on failure.
|
||||
pub async fn connect_provider(provider_name: &str) -> Result<ProviderConnection, String> {
|
||||
match provider_name.to_lowercase().as_str() {
|
||||
"browserbase" => {
|
||||
let (url, session) = connect_browserbase().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browserless" => {
|
||||
let (url, session) = connect_browserless().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browser-use" | "browseruse" => {
|
||||
let (url, session) = connect_browser_use().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"kernel" => {
|
||||
let (url, session) = connect_kernel().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"agentcore" => {
|
||||
let (url, session) = connect_agentcore().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
_ => Err(format!(
|
||||
"Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel, agentcore",
|
||||
provider_name
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a provider session (call on CDP connect failure).
|
||||
pub async fn close_provider_session(session: &ProviderSession) {
|
||||
let client = reqwest::Client::new();
|
||||
match session.provider.as_str() {
|
||||
"browserbase" => {
|
||||
if let Ok(api_key) = env::var("BROWSERBASE_API_KEY") {
|
||||
let _ = client
|
||||
.post(format!(
|
||||
"https://api.browserbase.com/v1/sessions/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.json(&serde_json::json!({ "status": "REQUEST_RELEASE" }))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"browser-use" => {
|
||||
if let Ok(api_key) = env::var("BROWSER_USE_API_KEY") {
|
||||
let _ = client
|
||||
.patch(format!(
|
||||
"https://api.browser-use.com/api/v2/browsers/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("X-Browser-Use-API-Key", &api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({ "action": "stop" }))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"browserless" => {
|
||||
// session_id holds the stop URL for browserless
|
||||
let _ = client.delete(&session.session_id).send().await;
|
||||
}
|
||||
"kernel" => {
|
||||
if let Ok(api_key) = env::var("KERNEL_API_KEY") {
|
||||
let endpoint = env::var("KERNEL_ENDPOINT")
|
||||
.unwrap_or_else(|_| "https://api.onkernel.com".to_string());
|
||||
let _ = client
|
||||
.delete(format!(
|
||||
"{}/browsers/{}",
|
||||
endpoint.trim_end_matches('/'),
|
||||
session.session_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"agentcore" => {
|
||||
// AgentCore session cleanup is handled via signed DELETE request
|
||||
let _ = close_agentcore_session(&session.session_id).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSERBASE_API_KEY")
|
||||
.map_err(|_| "BROWSERBASE_API_KEY environment variable is not set")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browserbase.com/v1/sessions")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-bb-api-key", &api_key)
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browserbase request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browserbase response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browserbase API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserbase response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("connectUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserbase response missing connectUrl".to_string())?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browserbase".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_browserless() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSERLESS_API_KEY")
|
||||
.map_err(|_| "BROWSERLESS_API_KEY environment variable is not set")?;
|
||||
|
||||
let api_url = env::var("BROWSERLESS_API_URL")
|
||||
.unwrap_or_else(|_| "https://production-sfo.browserless.io".to_string());
|
||||
let browser_type =
|
||||
env::var("BROWSERLESS_BROWSER_TYPE").unwrap_or_else(|_| "chromium".to_string());
|
||||
|
||||
let supported = ["chromium", "chrome"];
|
||||
if !supported.contains(&browser_type.as_str()) {
|
||||
return Err(format!(
|
||||
"BROWSERLESS_BROWSER_TYPE \"{}\" is not supported. Only {} are allowed.",
|
||||
browser_type,
|
||||
supported.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let ttl: u64 = env::var("BROWSERLESS_TTL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(300000);
|
||||
let stealth = env::var("BROWSERLESS_STEALTH")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true);
|
||||
|
||||
let url = format!("{}/session", api_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.query(&[("token", &api_key)])
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"ttl": ttl,
|
||||
"stealth": stealth,
|
||||
"browser": browser_type,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browserless request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browserless response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browserless API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserless response: {}", e))?;
|
||||
|
||||
let connect_url = json
|
||||
.get("connect")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserless response missing 'connect' URL".to_string())?;
|
||||
|
||||
let stop_url = json
|
||||
.get("stop")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserless response missing 'stop' URL".to_string())?;
|
||||
|
||||
Ok((
|
||||
connect_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browserless".to_string(),
|
||||
// Store the stop URL as the session_id for cleanup
|
||||
session_id: stop_url,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSER_USE_API_KEY")
|
||||
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
|
||||
|
||||
let ws_url = format!("wss://connect.browser-use.com?apiKey={}", api_key);
|
||||
|
||||
Ok((ws_url, None))
|
||||
}
|
||||
|
||||
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("KERNEL_API_KEY").ok();
|
||||
let endpoint =
|
||||
env::var("KERNEL_ENDPOINT").unwrap_or_else(|_| "https://api.onkernel.com".to_string());
|
||||
|
||||
let url = format!("{}/browsers", endpoint.trim_end_matches('/'));
|
||||
|
||||
let headless = env::var("KERNEL_HEADLESS")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true);
|
||||
let stealth = env::var("KERNEL_STEALTH")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
let timeout_seconds = env::var("KERNEL_TIMEOUT_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(300);
|
||||
|
||||
let mut body = json!({
|
||||
"headless": headless,
|
||||
"stealth": stealth,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
});
|
||||
|
||||
if let Ok(profile) = env::var("KERNEL_PROFILE_NAME") {
|
||||
if !profile.is_empty() {
|
||||
body.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("profile".to_string(), json!(profile));
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client.post(&url).header("Content-Type", "application/json");
|
||||
if let Some(ref key) = api_key {
|
||||
request = request.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
let response = request
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Kernel request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let resp_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Kernel response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Kernel API error ({}): {}",
|
||||
status.as_u16(),
|
||||
resp_body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid Kernel response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("session_id")
|
||||
.or_else(|| json.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("cdp_ws_url")
|
||||
.or_else(|| json.get("connectUrl"))
|
||||
.or_else(|| json.get("connect_url"))
|
||||
.or_else(|| json.get("cdpUrl"))
|
||||
.or_else(|| json.get("cdp_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| {
|
||||
"Kernel response missing cdp_ws_url, connectUrl, connect_url, cdpUrl, or cdp_url"
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "kernel".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentCore Provider (AWS Bedrock AgentCore Browser)
|
||||
// ============================================================================
|
||||
|
||||
mod agentcore {
|
||||
use super::*;
|
||||
|
||||
/// AgentCore-specific session info for Live View URL
|
||||
pub struct AgentCoreSessionInfo {
|
||||
pub session_id: String,
|
||||
pub browser_identifier: String,
|
||||
pub region: String,
|
||||
pub live_view_url: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static AGENTCORE_INFO: std::cell::RefCell<Option<AgentCoreSessionInfo>> = const { std::cell::RefCell::new(None) };
|
||||
static AGENTCORE_WS_HEADERS: std::cell::RefCell<Option<Vec<(String, String)>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn set_agentcore_info(info: AgentCoreSessionInfo) {
|
||||
AGENTCORE_INFO.with(|cell| *cell.borrow_mut() = Some(info));
|
||||
}
|
||||
|
||||
pub fn get_agentcore_info() -> Option<AgentCoreSessionInfo> {
|
||||
AGENTCORE_INFO.with(|cell| {
|
||||
cell.borrow().as_ref().map(|i| AgentCoreSessionInfo {
|
||||
session_id: i.session_id.clone(),
|
||||
browser_identifier: i.browser_identifier.clone(),
|
||||
region: i.region.clone(),
|
||||
live_view_url: i.live_view_url.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_agentcore_ws_headers(headers: Vec<(String, String)>) {
|
||||
AGENTCORE_WS_HEADERS.with(|cell| *cell.borrow_mut() = Some(headers));
|
||||
}
|
||||
|
||||
pub fn take_agentcore_ws_headers() -> Option<Vec<(String, String)>> {
|
||||
AGENTCORE_WS_HEADERS.with(|cell| cell.borrow_mut().take())
|
||||
}
|
||||
|
||||
pub async fn connect() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let region = env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| env::var("AWS_REGION"))
|
||||
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let browser_id =
|
||||
env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
let timeout_secs: u64 = env::var("AGENTCORE_SESSION_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3600);
|
||||
|
||||
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
|
||||
let path = format!(
|
||||
"/browsers/{}/sessions/start",
|
||||
urlencoding::encode(&browser_id)
|
||||
);
|
||||
let url = format!("https://{}{}", host, path);
|
||||
|
||||
// Generate a unique session name
|
||||
let session_name = format!("agent-browser-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let mut body_json = json!({
|
||||
"name": session_name,
|
||||
"sessionTimeoutSeconds": timeout_secs
|
||||
});
|
||||
if let Ok(profile_id) = env::var("AGENTCORE_PROFILE_ID") {
|
||||
if !profile_id.is_empty() {
|
||||
body_json.as_object_mut().unwrap().insert(
|
||||
"profileConfiguration".to_string(),
|
||||
json!({ "profileIdentifier": profile_id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
let body = serde_json::to_string(&body_json)
|
||||
.map_err(|e| format!("Failed to serialize request body: {}", e))?;
|
||||
|
||||
let signed_headers = sign_request("PUT", &url, ®ion, Some(&body)).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client.put(&url).body(body.clone());
|
||||
for (key, value) in &signed_headers {
|
||||
req = req.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("AgentCore request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let resp_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read AgentCore response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"AgentCore API error ({}): {}",
|
||||
status.as_u16(),
|
||||
resp_body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&resp_body)
|
||||
.map_err(|e| format!("Invalid AgentCore response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("sessionId")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "AgentCore response missing sessionId".to_string())?
|
||||
.to_string();
|
||||
|
||||
let browser_identifier = json
|
||||
.get("browserIdentifier")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&browser_id)
|
||||
.to_string();
|
||||
|
||||
let live_view_url = format!(
|
||||
"https://{}.console.aws.amazon.com/bedrock-agentcore/browser/{}/session/{}#",
|
||||
region, browser_identifier, session_id
|
||||
);
|
||||
|
||||
set_agentcore_info(AgentCoreSessionInfo {
|
||||
session_id: session_id.clone(),
|
||||
browser_identifier: browser_identifier.clone(),
|
||||
region: region.clone(),
|
||||
live_view_url: live_view_url.clone(),
|
||||
});
|
||||
|
||||
eprintln!("Session: {}", session_id);
|
||||
eprintln!("Live View: {}", live_view_url);
|
||||
|
||||
let ws_path = format!(
|
||||
"/browser-streams/{}/sessions/{}/automation",
|
||||
browser_identifier, session_id
|
||||
);
|
||||
let ws_url = format!("wss://{}{}", host, ws_path);
|
||||
|
||||
let ws_headers = sign_request(
|
||||
"GET",
|
||||
&format!("https://{}{}", host, ws_path),
|
||||
®ion,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
set_agentcore_ws_headers(ws_headers);
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "agentcore".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get AWS credentials from environment variables or AWS CLI
|
||||
fn get_aws_credentials() -> Result<(String, String, Option<String>), String> {
|
||||
// First try environment variables
|
||||
if let (Ok(access_key), Ok(secret_key)) = (
|
||||
env::var("AWS_ACCESS_KEY_ID"),
|
||||
env::var("AWS_SECRET_ACCESS_KEY"),
|
||||
) {
|
||||
return Ok((access_key, secret_key, env::var("AWS_SESSION_TOKEN").ok()));
|
||||
}
|
||||
|
||||
// Fall back to AWS CLI
|
||||
let mut cmd = std::process::Command::new("aws");
|
||||
cmd.args(["configure", "export-credentials", "--format", "env"]);
|
||||
|
||||
// Honor AWS_PROFILE
|
||||
if let Ok(profile) = env::var("AWS_PROFILE") {
|
||||
cmd.args(["--profile", &profile]);
|
||||
}
|
||||
|
||||
let output = cmd.output()
|
||||
.map_err(|e| format!("Failed to run aws CLI: {}. Install AWS CLI or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"AWS CLI failed: {}. Run 'aws sso login' or set credentials",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut access_key = None;
|
||||
let mut secret_key = None;
|
||||
let mut session_token = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(val) = line.strip_prefix("export AWS_ACCESS_KEY_ID=") {
|
||||
access_key = Some(val.to_string());
|
||||
} else if let Some(val) = line.strip_prefix("export AWS_SECRET_ACCESS_KEY=") {
|
||||
secret_key = Some(val.to_string());
|
||||
} else if let Some(val) = line.strip_prefix("export AWS_SESSION_TOKEN=") {
|
||||
session_token = Some(val.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match (access_key, secret_key) {
|
||||
(Some(ak), Some(sk)) => Ok((ak, sk, session_token)),
|
||||
_ => Err("Failed to parse credentials from AWS CLI output".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sign_request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
region: &str,
|
||||
body: Option<&str>,
|
||||
) -> Result<Vec<(String, String)>, String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Get credentials from environment or AWS CLI
|
||||
let (access_key, secret_key, session_token) = get_aws_credentials()?;
|
||||
|
||||
let parsed_url = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed_url.host_str().unwrap_or("");
|
||||
|
||||
// Get current time
|
||||
let now = chrono::Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
|
||||
// Create canonical request
|
||||
let payload_hash = if let Some(b) = body {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
} else {
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string()
|
||||
// empty string hash
|
||||
};
|
||||
|
||||
let canonical_uri = parsed_url.path();
|
||||
let canonical_querystring = parsed_url.query().unwrap_or("");
|
||||
|
||||
let mut signed_headers = "content-type;host;x-amz-date".to_string();
|
||||
let mut canonical_headers = format!(
|
||||
"content-type:application/json\nhost:{}\nx-amz-date:{}\n",
|
||||
host, amz_date
|
||||
);
|
||||
|
||||
if let Some(ref token) = session_token {
|
||||
signed_headers = "content-type;host;x-amz-date;x-amz-security-token".to_string();
|
||||
canonical_headers = format!(
|
||||
"content-type:application/json\nhost:{}\nx-amz-date:{}\nx-amz-security-token:{}\n",
|
||||
host, amz_date, token
|
||||
);
|
||||
}
|
||||
|
||||
let canonical_request = format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
method,
|
||||
canonical_uri,
|
||||
canonical_querystring,
|
||||
canonical_headers,
|
||||
signed_headers,
|
||||
payload_hash
|
||||
);
|
||||
|
||||
// Create string to sign
|
||||
let algorithm = "AWS4-HMAC-SHA256";
|
||||
let credential_scope = format!("{}/{}/bedrock-agentcore/aws4_request", date_stamp, region);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(canonical_request.as_bytes());
|
||||
let canonical_request_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let string_to_sign = format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
algorithm, amz_date, credential_scope, canonical_request_hash
|
||||
);
|
||||
|
||||
// Calculate signature
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let k_date = HmacSha256::new_from_slice(format!("AWS4{}", secret_key).as_bytes())
|
||||
.unwrap()
|
||||
.chain_update(date_stamp.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_region = HmacSha256::new_from_slice(&k_date)
|
||||
.unwrap()
|
||||
.chain_update(region.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_service = HmacSha256::new_from_slice(&k_region)
|
||||
.unwrap()
|
||||
.chain_update(b"bedrock-agentcore")
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_signing = HmacSha256::new_from_slice(&k_service)
|
||||
.unwrap()
|
||||
.chain_update(b"aws4_request")
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let signature = hex::encode(
|
||||
HmacSha256::new_from_slice(&k_signing)
|
||||
.unwrap()
|
||||
.chain_update(string_to_sign.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes(),
|
||||
);
|
||||
|
||||
// Build authorization header
|
||||
let authorization = format!(
|
||||
"{} Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
algorithm, access_key, credential_scope, signed_headers, signature
|
||||
);
|
||||
|
||||
let mut headers = vec![
|
||||
("host".to_string(), host.to_string()),
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
("x-amz-date".to_string(), amz_date),
|
||||
("authorization".to_string(), authorization),
|
||||
];
|
||||
|
||||
if let Some(token) = session_token {
|
||||
headers.push(("x-amz-security-token".to_string(), token));
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
pub async fn close_session(session_id: &str) -> Result<(), String> {
|
||||
let info = get_agentcore_info();
|
||||
let (region, browser_id) = match &info {
|
||||
Some(i) => (i.region.clone(), i.browser_identifier.clone()),
|
||||
None => {
|
||||
let region = env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| env::var("AWS_REGION"))
|
||||
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let browser_id = env::var("AGENTCORE_BROWSER_ID")
|
||||
.unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
(region, browser_id)
|
||||
}
|
||||
};
|
||||
|
||||
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
|
||||
let path = format!(
|
||||
"/browsers/{}/sessions/stop",
|
||||
urlencoding::encode(&browser_id)
|
||||
);
|
||||
let url = format!("https://{}{}", host, path);
|
||||
|
||||
let body = serde_json::to_string(&json!({ "sessionId": session_id }))
|
||||
.map_err(|e| format!("Failed to serialize close request: {}", e))?;
|
||||
|
||||
let signed_headers = sign_request("PUT", &url, ®ion, Some(&body)).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client.put(&url).body(body);
|
||||
for (key, value) in &signed_headers {
|
||||
req = req.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let _ = req.send().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub use agentcore::{get_agentcore_info, take_agentcore_ws_headers};
|
||||
|
||||
async fn connect_agentcore() -> Result<(String, Option<ProviderSession>), String> {
|
||||
agentcore::connect().await
|
||||
}
|
||||
|
||||
async fn close_agentcore_session(session_id: &str) -> Result<(), String> {
|
||||
agentcore::close_session(session_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_provider_unknown() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let result = rt.block_on(connect_provider("unknown-provider"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_env_defaults() {
|
||||
// Test that default values are used when env vars not set
|
||||
std::env::remove_var("AGENTCORE_REGION");
|
||||
std::env::remove_var("AGENTCORE_BROWSER_ID");
|
||||
std::env::remove_var("AGENTCORE_SESSION_TIMEOUT");
|
||||
|
||||
// These would be used in connect() - just verify they don't panic
|
||||
let region = std::env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| std::env::var("AWS_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
assert_eq!(region, "us-east-1");
|
||||
|
||||
let browser_id =
|
||||
std::env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
assert_eq!(browser_id, "aws.browser.v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_session_info_storage() {
|
||||
let info = agentcore::AgentCoreSessionInfo {
|
||||
session_id: "test-session".to_string(),
|
||||
browser_identifier: "aws.browser.v1".to_string(),
|
||||
region: "us-east-1".to_string(),
|
||||
live_view_url: "https://example.com".to_string(),
|
||||
};
|
||||
|
||||
agentcore::set_agentcore_info(info);
|
||||
let retrieved = get_agentcore_info();
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.session_id, "test-session");
|
||||
assert_eq!(retrieved.region, "us-east-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_ws_headers_storage() {
|
||||
let headers = vec![
|
||||
(
|
||||
"Authorization".to_string(),
|
||||
"AWS4-HMAC-SHA256...".to_string(),
|
||||
),
|
||||
("X-Amz-Date".to_string(), "20260304T180000Z".to_string()),
|
||||
];
|
||||
|
||||
agentcore::set_agentcore_ws_headers(headers);
|
||||
let taken = take_agentcore_ws_headers();
|
||||
assert!(taken.is_some());
|
||||
assert_eq!(taken.unwrap().len(), 2);
|
||||
|
||||
// Should be None after take
|
||||
let taken_again = take_agentcore_ws_headers();
|
||||
assert!(taken_again.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{CaptureScreenshotParams, CaptureScreenshotResult};
|
||||
|
||||
const CAPTURE_INTERVAL_MS: u64 = 100;
|
||||
const CAPTURE_FPS: u32 = 10;
|
||||
|
||||
pub struct RecordingState {
|
||||
pub active: bool,
|
||||
pub output_path: String,
|
||||
pub frame_count: u64,
|
||||
pub capture_task: Option<tokio::task::JoinHandle<Result<(), String>>>,
|
||||
pub shared_frame_count: Option<Arc<AtomicU64>>,
|
||||
pub cancel_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl RecordingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
output_path: String::new(),
|
||||
frame_count: 0,
|
||||
capture_task: None,
|
||||
shared_frame_count: None,
|
||||
cancel_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recording_start(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
if state.active {
|
||||
return Err("Recording already active".to_string());
|
||||
}
|
||||
|
||||
state.active = true;
|
||||
state.output_path = path.to_string();
|
||||
state.frame_count = 0;
|
||||
|
||||
Ok(json!({ "started": true, "path": path }))
|
||||
}
|
||||
|
||||
pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
|
||||
if !state.active {
|
||||
return Err("No recording in progress".to_string());
|
||||
}
|
||||
|
||||
state.active = false;
|
||||
|
||||
if state.frame_count == 0 {
|
||||
return Err("No frames captured".to_string());
|
||||
}
|
||||
|
||||
Ok(json!({ "path": &state.output_path, "frames": state.frame_count }))
|
||||
}
|
||||
|
||||
pub fn recording_restart(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
let previous = if state.active {
|
||||
let stop_result = recording_stop(state);
|
||||
stop_result
|
||||
.ok()
|
||||
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
recording_start(state, path)?;
|
||||
|
||||
Ok(json!({
|
||||
"restarted": true,
|
||||
"previousPath": previous,
|
||||
"path": path,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_ffmpeg_command(output_path: &str) -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new("ffmpeg");
|
||||
|
||||
cmd.args(["-y"])
|
||||
.args(["-avioflags", "direct"])
|
||||
.args([
|
||||
"-fpsprobesize",
|
||||
"0",
|
||||
"-probesize",
|
||||
"32",
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
])
|
||||
.args([
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-c:v",
|
||||
"mjpeg",
|
||||
"-framerate",
|
||||
&CAPTURE_FPS.to_string(),
|
||||
"-i",
|
||||
"pipe:0",
|
||||
])
|
||||
.args(["-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"]);
|
||||
|
||||
if output_path.ends_with(".webm") {
|
||||
cmd.args(["-c:v", "libvpx", "-crf", "30", "-b:v", "1M"]);
|
||||
} else {
|
||||
cmd.args(["-c:v", "libx264", "-preset", "ultrafast"]);
|
||||
}
|
||||
|
||||
cmd.args(["-pix_fmt", "yuv420p", "-threads", "1"])
|
||||
.arg(output_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Spawn a background task that captures screenshots at a fixed interval
|
||||
/// and pipes them to ffmpeg in real-time.
|
||||
pub fn spawn_recording_task(
|
||||
client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
output_path: String,
|
||||
shared_count: Arc<AtomicU64>,
|
||||
cancel_rx: oneshot::Receiver<()>,
|
||||
) -> tokio::task::JoinHandle<Result<(), String>> {
|
||||
tokio::spawn(async move {
|
||||
let mut cancel_rx = std::pin::pin!(cancel_rx);
|
||||
|
||||
let mut ffmpeg = build_ffmpeg_command(&output_path).spawn().map_err(|e| {
|
||||
format!(
|
||||
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut stdin = ffmpeg
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to open ffmpeg stdin".to_string())?;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(CAPTURE_INTERVAL_MS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let params = CaptureScreenshotParams {
|
||||
format: Some("jpeg".to_string()),
|
||||
quality: Some(80),
|
||||
clip: None,
|
||||
from_surface: Some(true),
|
||||
capture_beyond_viewport: None,
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut cancel_rx => break,
|
||||
_ = interval.tick() => {}
|
||||
}
|
||||
|
||||
let result: Result<CaptureScreenshotResult, _> = client
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(&session_id))
|
||||
.await;
|
||||
|
||||
let screenshot = match result {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if e.contains("Target closed") || e.contains("not found") {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = match base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&screenshot.data,
|
||||
) {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if stdin.write_all(&bytes).await.is_err() {
|
||||
break;
|
||||
}
|
||||
shared_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
drop(stdin);
|
||||
|
||||
let output = ffmpeg
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| format!("ffmpeg wait failed: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"ffmpeg failed: {}",
|
||||
stderr.chars().take(300).collect::<String>()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_recording_task(state: &mut RecordingState) -> Result<(), String> {
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
let counter = state.shared_frame_count.take();
|
||||
let handle = state.capture_task.take();
|
||||
|
||||
let result = if let Some(h) = handle {
|
||||
match h.await {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(e) => Err(format!("Recording task panicked: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Some(c) = counter {
|
||||
state.frame_count = c.load(Ordering::Relaxed);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_recording_state_new() {
|
||||
let state = RecordingState::new();
|
||||
assert!(!state.active);
|
||||
assert!(state.output_path.is_empty());
|
||||
assert_eq!(state.frame_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_sets_active() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_start(&mut state, "/tmp/test.mp4");
|
||||
assert!(result.is_ok());
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/test.mp4");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test1.mp4").unwrap();
|
||||
let result = recording_start(&mut state, "/tmp/test2.mp4");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("already active"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_stop_not_active() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_stop(&mut state);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No recording"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_stop_no_frames() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test.mp4").unwrap();
|
||||
let result = recording_stop(&mut state);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No frames"));
|
||||
assert!(!state.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_restart_while_inactive() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_restart(&mut state, "/tmp/new.webm");
|
||||
assert!(result.is_ok());
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/new.webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_restart_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/old.webm").unwrap();
|
||||
state.frame_count = 10;
|
||||
let result = recording_restart(&mut state, "/tmp/new.webm").unwrap();
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/new.webm");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
assert_eq!(result["previousPath"], "/tmp/old.webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ffmpeg_command_webm() {
|
||||
let cmd = build_ffmpeg_command("/tmp/out.webm");
|
||||
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
|
||||
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
|
||||
assert!(args_str.contains(&"libvpx"));
|
||||
assert!(args_str.contains(&"/tmp/out.webm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ffmpeg_command_mp4() {
|
||||
let cmd = build_ffmpeg_command("/tmp/out.mp4");
|
||||
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
|
||||
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
|
||||
assert!(args_str.contains(&"libx264"));
|
||||
assert!(args_str.contains(&"/tmp/out.mp4"));
|
||||
}
|
||||
}
|
||||
@@ -1,691 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::RefMap;
|
||||
|
||||
const ANNOTATION_OVERLAY_ID: &str = "__agent_browser_annotations__";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Rect {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RawAnnotation {
|
||||
ref_id: String,
|
||||
number: u64,
|
||||
role: String,
|
||||
name: Option<String>,
|
||||
rect: Rect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AnnotationBox {
|
||||
pub x: i64,
|
||||
pub y: i64,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotAnnotation {
|
||||
pub ref_id: String,
|
||||
pub number: u64,
|
||||
pub role: String,
|
||||
pub name: Option<String>,
|
||||
pub box_: AnnotationBox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotResult {
|
||||
pub path: String,
|
||||
pub base64: String,
|
||||
pub annotations: Vec<ScreenshotAnnotation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub full_page: bool,
|
||||
pub format: String,
|
||||
pub quality: Option<i32>,
|
||||
pub annotate: bool,
|
||||
pub output_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
path: None,
|
||||
full_page: false,
|
||||
format: "png".to_string(),
|
||||
quality: None,
|
||||
annotate: false,
|
||||
output_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ScreenshotAnnotation {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
|
||||
let mut state = serializer.serialize_struct("ScreenshotAnnotation", 5)?;
|
||||
state.serialize_field("ref", &self.ref_id)?;
|
||||
state.serialize_field("number", &self.number)?;
|
||||
state.serialize_field("role", &self.role)?;
|
||||
if let Some(name) = &self.name {
|
||||
state.serialize_field("name", name)?;
|
||||
}
|
||||
state.serialize_field("box", &self.box_)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures a screenshot via CDP and optionally overlays numbered annotations
|
||||
/// that mirror the Node.js screenshot `annotate` mode.
|
||||
pub async fn take_screenshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<ScreenshotResult, String> {
|
||||
let target_rect = if options.annotate {
|
||||
match options.selector.as_deref() {
|
||||
Some(selector) => {
|
||||
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let raw_annotations = if options.annotate {
|
||||
collect_annotations(client, session_id, ref_map).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let overlay_items = filter_annotations(raw_annotations, target_rect.as_ref());
|
||||
let overlay_injected = if options.annotate && !overlay_items.is_empty() {
|
||||
inject_annotation_overlay(client, session_id, &overlay_items).await?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let base64 =
|
||||
capture_screenshot_base64(client, session_id, ref_map, options, iframe_sessions).await;
|
||||
|
||||
if overlay_injected {
|
||||
let _ = remove_annotation_overlay(client, session_id).await;
|
||||
}
|
||||
|
||||
let base64 = base64?;
|
||||
let annotations = if options.annotate {
|
||||
let scroll = if options.full_page {
|
||||
Some(get_scroll_offsets(client, session_id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
project_annotations(&overlay_items, target_rect.as_ref(), scroll)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let ext = if options.format == "jpeg" {
|
||||
"jpg"
|
||||
} else {
|
||||
"png"
|
||||
};
|
||||
let path = save_screenshot(
|
||||
&base64,
|
||||
options.path.as_deref(),
|
||||
ext,
|
||||
options.output_dir.as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(ScreenshotResult {
|
||||
path,
|
||||
base64,
|
||||
annotations,
|
||||
})
|
||||
}
|
||||
|
||||
async fn capture_screenshot_base64(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let mut params = CaptureScreenshotParams {
|
||||
format: Some(options.format.clone()),
|
||||
quality: if options.format == "jpeg" {
|
||||
options.quality.or(Some(80))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
clip: None,
|
||||
from_surface: Some(true),
|
||||
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
|
||||
};
|
||||
|
||||
if options.full_page {
|
||||
let metrics: Value = client
|
||||
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let content_size = metrics
|
||||
.get("contentSize")
|
||||
.or_else(|| metrics.get("cssContentSize"));
|
||||
if let Some(size) = content_size {
|
||||
let width = size.get("width").and_then(|v| v.as_f64()).unwrap_or(1280.0);
|
||||
let height = size.get("height").and_then(|v| v.as_f64()).unwrap_or(720.0);
|
||||
|
||||
params.clip = Some(Viewport {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width,
|
||||
height,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
} else if let Some(ref selector) = options.selector {
|
||||
if let Some(rect) =
|
||||
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions).await?
|
||||
{
|
||||
params.clip = Some(Viewport {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result: CaptureScreenshotResult = client
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(session_id))
|
||||
.await?;
|
||||
|
||||
Ok(result.data)
|
||||
}
|
||||
|
||||
async fn collect_annotations(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
) -> Result<Vec<RawAnnotation>, String> {
|
||||
let entries = ref_map.entries_sorted();
|
||||
if entries.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Collect entries that have backend_node_ids for batch resolution.
|
||||
let with_backend_ids: Vec<(String, super::element::RefEntry, i64)> = entries
|
||||
.iter()
|
||||
.filter_map(|(ref_id, entry)| {
|
||||
entry
|
||||
.backend_node_id
|
||||
.map(|bid| (ref_id.clone(), entry.clone(), bid))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if with_backend_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Batch-resolve all backend_node_ids to object IDs using concurrent CDP calls.
|
||||
let resolve_futures: Vec<_> = with_backend_ids
|
||||
.iter()
|
||||
.map(|(_, _, backend_node_id)| {
|
||||
client.send_command(
|
||||
"DOM.resolveNode",
|
||||
Some(serde_json::json!({
|
||||
"backendNodeId": backend_node_id,
|
||||
"objectGroup": "agent-browser-annotate"
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resolve_results = futures_util::future::join_all(resolve_futures).await;
|
||||
|
||||
// Collect resolved object IDs paired with their ref info.
|
||||
let mut resolved: Vec<(String, super::element::RefEntry, String)> = Vec::new();
|
||||
for (i, result) in resolve_results.into_iter().enumerate() {
|
||||
if let Ok(val) = result {
|
||||
if let Some(oid) = val
|
||||
.get("object")
|
||||
.and_then(|o| o.get("objectId"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let (ref_id, entry, _) = &with_backend_ids[i];
|
||||
resolved.push((ref_id.clone(), entry.clone(), oid.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Batch-get bounding rects for all resolved elements using concurrent CDP calls.
|
||||
let rect_futures: Vec<_> = resolved
|
||||
.iter()
|
||||
.map(|(_, _, object_id)| get_rect_for_object(client, session_id, object_id))
|
||||
.collect();
|
||||
|
||||
let rect_results = futures_util::future::join_all(rect_futures).await;
|
||||
|
||||
let mut annotations = Vec::new();
|
||||
for (i, rect_result) in rect_results.into_iter().enumerate() {
|
||||
let rect = match rect_result {
|
||||
Ok(Some(r)) if r.width > 0.0 && r.height > 0.0 => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let (ref_id, entry, _) = &resolved[i];
|
||||
let number = ref_id
|
||||
.strip_prefix('e')
|
||||
.and_then(|n| n.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
annotations.push(RawAnnotation {
|
||||
ref_id: ref_id.clone(),
|
||||
number,
|
||||
role: entry.role.clone(),
|
||||
name: (!entry.name.is_empty()).then_some(entry.name.clone()),
|
||||
rect,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(annotations)
|
||||
}
|
||||
|
||||
async fn get_rect_for_selector(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Option<Rect>, String> {
|
||||
let (object_id, effective_session_id) = super::element::resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
get_rect_for_object(client, &effective_session_id, &object_id).await
|
||||
}
|
||||
|
||||
async fn get_rect_for_object(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
object_id: &str,
|
||||
) -> Result<Option<Rect>, String> {
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id.to_string()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.as_ref().and_then(parse_rect))
|
||||
}
|
||||
|
||||
fn parse_rect(value: &Value) -> Option<Rect> {
|
||||
Some(Rect {
|
||||
x: value.get("x")?.as_f64()?,
|
||||
y: value.get("y")?.as_f64()?,
|
||||
width: value.get("width")?.as_f64()?,
|
||||
height: value.get("height")?.as_f64()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn filter_annotations(
|
||||
annotations: Vec<RawAnnotation>,
|
||||
target_rect: Option<&Rect>,
|
||||
) -> Vec<RawAnnotation> {
|
||||
let mut items = annotations
|
||||
.into_iter()
|
||||
.filter(|annotation| match target_rect {
|
||||
Some(target) => overlaps(&annotation.rect, target),
|
||||
None => true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
items.sort_by_key(|annotation| annotation.number);
|
||||
items
|
||||
}
|
||||
|
||||
fn overlaps(left: &Rect, right: &Rect) -> bool {
|
||||
let left_x2 = left.x + left.width;
|
||||
let left_y2 = left.y + left.height;
|
||||
let right_x2 = right.x + right.width;
|
||||
let right_y2 = right.y + right.height;
|
||||
|
||||
left.x < right_x2 && left_x2 > right.x && left.y < right_y2 && left_y2 > right.y
|
||||
}
|
||||
|
||||
async fn inject_annotation_overlay(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
annotations: &[RawAnnotation],
|
||||
) -> Result<(), String> {
|
||||
let overlay_data = annotations
|
||||
.iter()
|
||||
.map(|annotation| {
|
||||
serde_json::json!({
|
||||
"number": annotation.number,
|
||||
"x": round(annotation.rect.x),
|
||||
"y": round(annotation.rect.y),
|
||||
"width": round(annotation.rect.width),
|
||||
"height": round(annotation.rect.height),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let expression = format!(
|
||||
r#"(() => {{
|
||||
var items = {items};
|
||||
var id = {overlay_id};
|
||||
var existing = document.getElementById(id);
|
||||
if (existing) existing.remove();
|
||||
var sx = window.scrollX || 0;
|
||||
var sy = window.scrollY || 0;
|
||||
var c = document.createElement('div');
|
||||
c.id = id;
|
||||
c.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;';
|
||||
for (var i = 0; i < items.length; i++) {{
|
||||
var it = items[i];
|
||||
var dx = it.x + sx;
|
||||
var dy = it.y + sy;
|
||||
var b = document.createElement('div');
|
||||
b.style.cssText = 'position:absolute;left:' + dx + 'px;top:' + dy + 'px;width:' + it.width + 'px;height:' + it.height + 'px;border:2px solid rgba(255,0,0,0.8);box-sizing:border-box;pointer-events:none;';
|
||||
var l = document.createElement('div');
|
||||
l.textContent = String(it.number);
|
||||
var labelTop = dy < 14 ? '2px' : '-14px';
|
||||
l.style.cssText = 'position:absolute;top:' + labelTop + ';left:-2px;background:rgba(255,0,0,0.9);color:#fff;font:bold 11px/14px monospace;padding:0 4px;border-radius:2px;white-space:nowrap;';
|
||||
b.appendChild(l);
|
||||
c.appendChild(b);
|
||||
}}
|
||||
document.documentElement.appendChild(c);
|
||||
return true;
|
||||
}})()"#,
|
||||
items = serde_json::to_string(&overlay_data).unwrap_or_else(|_| "[]".to_string()),
|
||||
overlay_id =
|
||||
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
);
|
||||
|
||||
let _: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_annotation_overlay(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
let expression = format!(
|
||||
r#"(() => {{
|
||||
var el = document.getElementById({overlay_id});
|
||||
if (el) el.remove();
|
||||
return true;
|
||||
}})()"#,
|
||||
overlay_id =
|
||||
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
);
|
||||
|
||||
let _: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_scroll_offsets(client: &CdpClient, session_id: &str) -> Result<(f64, f64), String> {
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: "({x: window.scrollX || 0, y: window.scrollY || 0})".to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let value = result.result.value.unwrap_or(Value::Null);
|
||||
let x = value.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = value.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
Ok((x, y))
|
||||
}
|
||||
|
||||
fn project_annotations(
|
||||
annotations: &[RawAnnotation],
|
||||
target_rect: Option<&Rect>,
|
||||
scroll: Option<(f64, f64)>,
|
||||
) -> Vec<ScreenshotAnnotation> {
|
||||
annotations
|
||||
.iter()
|
||||
.map(|annotation| {
|
||||
let rect = if let Some(target) = target_rect {
|
||||
Rect {
|
||||
x: annotation.rect.x - target.x,
|
||||
y: annotation.rect.y - target.y,
|
||||
width: annotation.rect.width,
|
||||
height: annotation.rect.height,
|
||||
}
|
||||
} else if let Some((scroll_x, scroll_y)) = scroll {
|
||||
Rect {
|
||||
x: annotation.rect.x + scroll_x,
|
||||
y: annotation.rect.y + scroll_y,
|
||||
width: annotation.rect.width,
|
||||
height: annotation.rect.height,
|
||||
}
|
||||
} else {
|
||||
annotation.rect.clone()
|
||||
};
|
||||
|
||||
ScreenshotAnnotation {
|
||||
ref_id: annotation.ref_id.clone(),
|
||||
number: annotation.number,
|
||||
role: annotation.role.clone(),
|
||||
name: annotation.name.clone(),
|
||||
box_: AnnotationBox {
|
||||
x: round(rect.x),
|
||||
y: round(rect.y),
|
||||
width: round(rect.width),
|
||||
height: round(rect.height),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_screenshot(
|
||||
base64_data: &str,
|
||||
explicit_path: Option<&str>,
|
||||
ext: &str,
|
||||
output_dir: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let save_path = match explicit_path {
|
||||
Some(path) => path.to_string(),
|
||||
None => {
|
||||
let dir = match output_dir {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => get_screenshot_dir(),
|
||||
};
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let name = format!("screenshot-{}.{}", timestamp, ext);
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
|
||||
.map_err(|e| format!("Failed to decode screenshot: {}", e))?;
|
||||
|
||||
std::fs::write(&save_path, &bytes)
|
||||
.map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?;
|
||||
|
||||
Ok(save_path)
|
||||
}
|
||||
|
||||
fn round(value: f64) -> i64 {
|
||||
value.round() as i64
|
||||
}
|
||||
|
||||
fn get_screenshot_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("screenshots")
|
||||
} else {
|
||||
std::env::temp_dir()
|
||||
.join("agent-browser")
|
||||
.join("screenshots")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filters_annotations_to_target_overlap() {
|
||||
let annotations = vec![
|
||||
RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Inside".to_string()),
|
||||
rect: Rect {
|
||||
x: 10.0,
|
||||
y: 10.0,
|
||||
width: 50.0,
|
||||
height: 20.0,
|
||||
},
|
||||
},
|
||||
RawAnnotation {
|
||||
ref_id: "e2".to_string(),
|
||||
number: 2,
|
||||
role: "button".to_string(),
|
||||
name: Some("Outside".to_string()),
|
||||
rect: Rect {
|
||||
x: 200.0,
|
||||
y: 200.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let target = Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
};
|
||||
|
||||
let filtered = filter_annotations(annotations, Some(&target));
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].ref_id, "e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_selector_annotations_relative_to_target() {
|
||||
let annotations = vec![RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Inside".to_string()),
|
||||
rect: Rect {
|
||||
x: 25.0,
|
||||
y: 35.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
}];
|
||||
|
||||
let target = Rect {
|
||||
x: 10.0,
|
||||
y: 15.0,
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
};
|
||||
|
||||
let projected = project_annotations(&annotations, Some(&target), None);
|
||||
assert_eq!(projected[0].box_.x, 15);
|
||||
assert_eq!(projected[0].box_.y, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_full_page_annotations_to_document_space() {
|
||||
let annotations = vec![RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Bottom".to_string()),
|
||||
rect: Rect {
|
||||
x: 5.0,
|
||||
y: 12.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
}];
|
||||
|
||||
let projected = project_annotations(&annotations, None, Some((10.0, 1000.0)));
|
||||
assert_eq!(projected[0].box_.x, 15);
|
||||
assert_eq!(projected[0].box_.y, 1012);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,887 +0,0 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{
|
||||
AttachToTargetParams, AttachToTargetResult, CloseTargetParams, CreateTargetParams,
|
||||
CreateTargetResult, EvaluateParams,
|
||||
};
|
||||
use super::cookies::{self, Cookie};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageState {
|
||||
pub cookies: Vec<Cookie>,
|
||||
pub origins: Vec<OriginStorage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OriginStorage {
|
||||
pub origin: String,
|
||||
pub local_storage: Vec<StorageEntry>,
|
||||
#[serde(default)]
|
||||
pub session_storage: Vec<StorageEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageEntry {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
fn collect_frame_origins(tree: &Value, origins: &mut HashSet<String>) {
|
||||
if let Some(frame) = tree.get("frame") {
|
||||
if let Some(url_str) = frame.get("url").and_then(|v| v.as_str()) {
|
||||
if let Ok(parsed) = url::Url::parse(url_str) {
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
if origin != "null" && !origin.is_empty() {
|
||||
origins.insert(origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
collect_frame_origins(child, origins);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the JS-evaluated origin storage data into an OriginStorage struct.
|
||||
fn parse_origin_storage(data: &Value) -> Option<OriginStorage> {
|
||||
if !data.is_object() {
|
||||
return None;
|
||||
}
|
||||
let origin = data
|
||||
.get("origin")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if origin.is_empty() || origin == "null" {
|
||||
return None;
|
||||
}
|
||||
let local_storage: Vec<StorageEntry> = data
|
||||
.get("localStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let session_storage: Vec<StorageEntry> = data
|
||||
.get("sessionStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(OriginStorage {
|
||||
origin,
|
||||
local_storage,
|
||||
session_storage,
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate the storage-collection JS snippet and parse the result.
|
||||
async fn eval_origin_storage(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
origin_js: &str,
|
||||
) -> Option<OriginStorage> {
|
||||
let result = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: origin_js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let data = result.result.value.unwrap_or(Value::Null);
|
||||
parse_origin_storage(&data)
|
||||
}
|
||||
|
||||
/// Create a temporary CDP target, navigate it to each origin to collect localStorage,
|
||||
/// then close it. Uses Fetch interception to serve blank HTML instead of making real
|
||||
/// network requests.
|
||||
async fn collect_storage_via_temp_target(
|
||||
client: &CdpClient,
|
||||
origins: &[String],
|
||||
origin_js: &str,
|
||||
) -> Result<Vec<OriginStorage>, String> {
|
||||
let create_result: CreateTargetResult = client
|
||||
.send_command_typed(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let target_id = create_result.target_id;
|
||||
|
||||
// Ensure the target is closed even if attach or later steps fail
|
||||
let result = collect_storage_in_target(client, &target_id, origins, origin_js).await;
|
||||
|
||||
let _ = client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Target.closeTarget",
|
||||
&CloseTargetParams { target_id },
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn collect_storage_in_target(
|
||||
client: &CdpClient,
|
||||
target_id: &str,
|
||||
origins: &[String],
|
||||
origin_js: &str,
|
||||
) -> Result<Vec<OriginStorage>, String> {
|
||||
let attach_result: AttachToTargetResult = client
|
||||
.send_command_typed(
|
||||
"Target.attachToTarget",
|
||||
&AttachToTargetParams {
|
||||
target_id: target_id.to_string(),
|
||||
flatten: true,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let temp_session = &attach_result.session_id;
|
||||
|
||||
client
|
||||
.send_command_no_params("Page.enable", Some(temp_session))
|
||||
.await?;
|
||||
client
|
||||
.send_command_no_params("Runtime.enable", Some(temp_session))
|
||||
.await?;
|
||||
|
||||
// Blank HTML response body, pre-encoded to avoid repeated base64 work per request
|
||||
let blank_html_b64 = base64::engine::general_purpose::STANDARD.encode("<html></html>");
|
||||
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Fetch.enable",
|
||||
Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut event_rx = client.subscribe();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for target_origin in origins {
|
||||
let nav_url = format!("{}/", target_origin.trim_end_matches('/'));
|
||||
if client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": nav_url })),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fulfill intercepted requests with blank HTML until the page loads
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5);
|
||||
let mut page_loaded = false;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(tokio::time::Duration::from_secs(2), event_rx.recv()).await {
|
||||
Ok(Ok(evt)) if evt.session_id.as_deref() == Some(temp_session) => {
|
||||
if evt.method == "Fetch.requestPaused" {
|
||||
if let Some(request_id) =
|
||||
evt.params.get("requestId").and_then(|v| v.as_str())
|
||||
{
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Fetch.fulfillRequest",
|
||||
Some(json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": 200,
|
||||
"responseHeaders": [
|
||||
{ "name": "Content-Type", "value": "text/html" }
|
||||
],
|
||||
"body": &blank_html_b64
|
||||
})),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else if evt.method == "Page.loadEventFired" {
|
||||
page_loaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Ok(_)) => continue, // event for a different session
|
||||
Ok(Err(_)) => continue, // lagged or closed — retry within deadline
|
||||
Err(_) => break, // outer timeout elapsed
|
||||
}
|
||||
}
|
||||
|
||||
if !page_loaded {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(storage) = eval_origin_storage(client, temp_session, origin_js).await {
|
||||
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
|
||||
results.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub async fn save_state(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
path: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
session_id_str: &str,
|
||||
visited_origins: &HashSet<String>,
|
||||
) -> Result<String, String> {
|
||||
let cookies = cookies::get_all_cookies(client, session_id).await?;
|
||||
|
||||
let origin_js = r#"(() => {
|
||||
const result = { origin: location.origin, localStorage: [], sessionStorage: [] };
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
result.localStorage.push({ name: key, value: localStorage.getItem(key) });
|
||||
}
|
||||
} catch(e) {}
|
||||
try {
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
result.sessionStorage.push({ name: key, value: sessionStorage.getItem(key) });
|
||||
}
|
||||
} catch(e) {}
|
||||
return result;
|
||||
})()"#;
|
||||
|
||||
// Merge visited origins with current frame tree origins
|
||||
let mut all_origins = visited_origins.clone();
|
||||
if let Ok(tree_result) = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(session_id))
|
||||
.await
|
||||
{
|
||||
if let Some(tree) = tree_result.get("frameTree") {
|
||||
collect_frame_origins(tree, &mut all_origins);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Collect localStorage from the current page
|
||||
let mut origins = Vec::new();
|
||||
let mut current_origin = String::new();
|
||||
|
||||
if let Some(storage) = eval_origin_storage(client, session_id, origin_js).await {
|
||||
current_origin = storage.origin.clone();
|
||||
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
|
||||
origins.push(storage);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collect localStorage from remaining origins via a disposable temp target
|
||||
all_origins.remove(¤t_origin);
|
||||
if !all_origins.is_empty() {
|
||||
let remaining: Vec<String> = all_origins.into_iter().collect();
|
||||
if let Ok(temp_origins) =
|
||||
collect_storage_via_temp_target(client, &remaining, origin_js).await
|
||||
{
|
||||
origins.extend(temp_origins);
|
||||
}
|
||||
}
|
||||
|
||||
let state = StorageState { cookies, origins };
|
||||
let json_str = serde_json::to_string_pretty(&state)
|
||||
.map_err(|e| format!("Failed to serialize state: {}", e))?;
|
||||
|
||||
let mut save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_sessions_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let name = session_name.unwrap_or("default");
|
||||
dir.join(format!("{}-{}.json", name, session_id_str))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
|
||||
let encrypted = encrypt_data(json_str.as_bytes(), &key)?;
|
||||
save_path.push_str(".enc");
|
||||
fs::write(&save_path, &encrypted)
|
||||
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
|
||||
} else {
|
||||
fs::write(&save_path, &json_str)
|
||||
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
|
||||
}
|
||||
|
||||
Ok(save_path)
|
||||
}
|
||||
|
||||
pub async fn load_state(client: &CdpClient, session_id: &str, path: &str) -> Result<(), String> {
|
||||
let json_str = if path.ends_with(".enc") {
|
||||
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
|
||||
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
|
||||
})?;
|
||||
let data =
|
||||
fs::read(path).map_err(|e| format!("Failed to read state from {}: {}", path, e))?;
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
|
||||
} else {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
|
||||
let enc_path = format!("{}.enc", path);
|
||||
if let Ok(data) = fs::read(&enc_path) {
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|de| format!("Decrypted state is not valid UTF-8: {}", de))?
|
||||
} else {
|
||||
return Err(format!("Failed to read state from {}: {}", path, e));
|
||||
}
|
||||
} else {
|
||||
return Err(format!("Failed to read state from {}: {}", path, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let state: StorageState =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
|
||||
|
||||
// Load cookies
|
||||
if !state.cookies.is_empty() {
|
||||
let cookie_values: Vec<Value> = state
|
||||
.cookies
|
||||
.iter()
|
||||
.map(|c| serde_json::to_value(c).unwrap_or(Value::Null))
|
||||
.collect();
|
||||
cookies::set_cookies(client, session_id, cookie_values, None).await?;
|
||||
}
|
||||
|
||||
// Load storage per origin
|
||||
for origin in &state.origins {
|
||||
if origin.local_storage.is_empty() && origin.session_storage.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Navigate to origin to set storage
|
||||
let navigate_url = format!("{}/", origin.origin.trim_end_matches('/'));
|
||||
client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": navigate_url })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Brief wait for navigation
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
for entry in &origin.local_storage {
|
||||
let js = format!(
|
||||
"localStorage.setItem({}, {})",
|
||||
serde_json::to_string(&entry.name).unwrap_or_default(),
|
||||
serde_json::to_string(&entry.value).unwrap_or_default(),
|
||||
);
|
||||
let _ = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
for entry in &origin.session_storage {
|
||||
let js = format!(
|
||||
"sessionStorage.setItem({}, {})",
|
||||
serde_json::to_string(&entry.name).unwrap_or_default(),
|
||||
serde_json::to_string(&entry.value).unwrap_or_default(),
|
||||
);
|
||||
let _ = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_state_file(path: &std::path::Path) -> bool {
|
||||
let fname = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
fname.ends_with(".json") || fname.ends_with(".json.enc")
|
||||
}
|
||||
|
||||
fn is_encrypted_state(path: &std::path::Path) -> bool {
|
||||
path.to_string_lossy().ends_with(".json.enc")
|
||||
}
|
||||
|
||||
pub fn state_list() -> Result<Value, String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "files": [], "directory": dir.to_string_lossy() }));
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read sessions dir: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if is_state_file(&path) {
|
||||
let metadata = fs::metadata(&path).ok();
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
let modified = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let encrypted = is_encrypted_state(&path);
|
||||
|
||||
files.push(json!({
|
||||
"filename": filename,
|
||||
"path": path.to_string_lossy(),
|
||||
"size": size,
|
||||
"modified": modified,
|
||||
"encrypted": encrypted,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "files": files, "directory": dir.to_string_lossy() }))
|
||||
}
|
||||
|
||||
pub fn state_show(path: &str) -> Result<Value, String> {
|
||||
let encrypted = path.ends_with(".enc");
|
||||
let json_str = if encrypted {
|
||||
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
|
||||
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
|
||||
})?;
|
||||
let data = fs::read(path).map_err(|e| format!("Failed to read state file: {}", e))?;
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
|
||||
} else {
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read state file: {}", e))?
|
||||
};
|
||||
|
||||
let state: StorageState =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
|
||||
|
||||
let metadata = fs::metadata(path).ok();
|
||||
let filename = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(json!({
|
||||
"filename": filename,
|
||||
"path": path,
|
||||
"size": metadata.as_ref().map(|m| m.len()).unwrap_or(0),
|
||||
"modified": metadata.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
"encrypted": encrypted,
|
||||
"summary": format!("{} cookies, {} origins", state.cookies.len(), state.origins.len()),
|
||||
"state": state,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn state_clear(path: Option<&str>) -> Result<Value, String> {
|
||||
if let Some(p) = path {
|
||||
fs::remove_file(p).map_err(|e| format!("Failed to delete state: {}", e))?;
|
||||
return Ok(json!({ "deleted": p }));
|
||||
}
|
||||
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "deleted": 0 }));
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if is_state_file(&path) {
|
||||
let _ = fs::remove_file(&path);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "deleted": count }))
|
||||
}
|
||||
|
||||
pub fn state_clean(max_age_days: u64) -> Result<Value, String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "cleaned": 0, "keptCount": 0, "days": max_age_days }));
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now();
|
||||
let max_age = std::time::Duration::from_secs(max_age_days * 86400);
|
||||
let mut deleted = 0;
|
||||
let mut kept = 0;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !is_state_file(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(metadata) = fs::metadata(&path) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(age) = now.duration_since(modified) {
|
||||
if age > max_age {
|
||||
let _ = fs::remove_file(&path);
|
||||
deleted += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "cleaned": deleted, "keptCount": kept, "days": max_age_days }))
|
||||
}
|
||||
|
||||
pub fn state_rename(old_path: &str, new_name: &str) -> Result<Value, String> {
|
||||
let old = PathBuf::from(old_path);
|
||||
if !old.exists() {
|
||||
return Err(format!("State file not found: {}", old_path));
|
||||
}
|
||||
|
||||
let fallback = PathBuf::from(".");
|
||||
let dir = old.parent().unwrap_or(&fallback);
|
||||
let new_path = dir.join(format!("{}.json", new_name));
|
||||
|
||||
fs::rename(&old, &new_path).map_err(|e| format!("Failed to rename state: {}", e))?;
|
||||
|
||||
Ok(json!({
|
||||
"renamed": true,
|
||||
"from": old_path,
|
||||
"to": new_path.to_string_lossy(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn encrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_str.as_bytes());
|
||||
let key_bytes = hasher.finalize();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
|
||||
|
||||
let mut nonce = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&nonce), data)
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn decrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
|
||||
if data.len() < 13 {
|
||||
return Err("Ciphertext too short".to_string());
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_str.as_bytes());
|
||||
let key_bytes = hasher.finalize();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
pub fn find_auto_state_file(session_name: &str) -> Option<String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("{}-", session_name);
|
||||
let mut best_path: Option<(String, std::time::SystemTime)> = None;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let fname = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let is_match = fname.starts_with(&prefix)
|
||||
&& (fname.ends_with(".json") || fname.ends_with(".json.enc"));
|
||||
if !is_match {
|
||||
continue;
|
||||
}
|
||||
let modified = fs::metadata(&path)
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best_path = Some((path.to_string_lossy().to_string(), modified));
|
||||
}
|
||||
}
|
||||
}
|
||||
best_path.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Dispatch a state management command from its JSON payload.
|
||||
/// Returns `Some(result)` for recognised state_* actions, `None` otherwise.
|
||||
pub fn dispatch_state_command(cmd: &Value) -> Option<Result<Value, String>> {
|
||||
let action = cmd.get("action").and_then(|v| v.as_str())?;
|
||||
match action {
|
||||
"state_list" => Some(state_list()),
|
||||
"state_show" => Some(
|
||||
cmd.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'path' parameter".to_string())
|
||||
.and_then(state_show),
|
||||
),
|
||||
"state_clear" => {
|
||||
let path = cmd.get("path").and_then(|v| v.as_str());
|
||||
Some(state_clear(path))
|
||||
}
|
||||
"state_clean" => {
|
||||
let days = cmd.get("days").and_then(|v| v.as_u64()).unwrap_or(30);
|
||||
Some(state_clean(days))
|
||||
}
|
||||
"state_rename" => Some(
|
||||
cmd.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'path' parameter".to_string())
|
||||
.and_then(|path| {
|
||||
cmd.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'name' parameter".to_string())
|
||||
.and_then(|name| state_rename(path, name))
|
||||
}),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sessions_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("sessions")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("sessions")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_storage_state_serialization() {
|
||||
let state = StorageState {
|
||||
cookies: vec![Cookie {
|
||||
name: "session".to_string(),
|
||||
value: "abc123".to_string(),
|
||||
domain: ".example.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 0.0,
|
||||
size: 0,
|
||||
http_only: true,
|
||||
secure: false,
|
||||
session: true,
|
||||
same_site: Some("Lax".to_string()),
|
||||
}],
|
||||
origins: vec![OriginStorage {
|
||||
origin: "https://example.com".to_string(),
|
||||
local_storage: vec![StorageEntry {
|
||||
name: "key".to_string(),
|
||||
value: "val".to_string(),
|
||||
}],
|
||||
session_storage: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&state).unwrap();
|
||||
let parsed: StorageState = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.cookies.len(), 1);
|
||||
assert_eq!(parsed.cookies[0].name, "session");
|
||||
assert_eq!(parsed.origins.len(), 1);
|
||||
assert_eq!(parsed.origins[0].local_storage.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_state_empty() {
|
||||
let state = StorageState {
|
||||
cookies: vec![],
|
||||
origins: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let parsed: StorageState = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.cookies.is_empty());
|
||||
assert!(parsed.origins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_show_nonexistent_file() {
|
||||
let result = state_show("/tmp/nonexistent-agent-browser-state-file.json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_clear_nonexistent_file() {
|
||||
let result = state_clear(Some("/tmp/nonexistent-agent-browser-state-file.json"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_rename_nonexistent() {
|
||||
let result = state_rename("/tmp/nonexistent-agent-browser-state-file.json", "new-name");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_list_returns_json() {
|
||||
let result = state_list().unwrap();
|
||||
assert!(result.get("files").is_some());
|
||||
assert!(result.get("directory").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_dir_path() {
|
||||
let dir = get_sessions_dir();
|
||||
assert!(dir.to_string_lossy().contains("sessions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let plain = b"hello world";
|
||||
let key = "test-secret-key";
|
||||
let encrypted = encrypt_data(plain, key).unwrap();
|
||||
assert!(encrypted.len() > 12);
|
||||
assert_ne!(&encrypted[12..], plain);
|
||||
let decrypted = decrypt_data(&encrypted, key).unwrap();
|
||||
assert_eq!(decrypted, plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_wrong_key_fails() {
|
||||
let plain = b"secret data";
|
||||
let encrypted = encrypt_data(plain, "key1").unwrap();
|
||||
let result = decrypt_data(&encrypted, "key2");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookie_serde_roundtrip() {
|
||||
let cookie = Cookie {
|
||||
name: "test".to_string(),
|
||||
value: "123".to_string(),
|
||||
domain: ".test.com".to_string(),
|
||||
path: "/api".to_string(),
|
||||
expires: 1700000000.0,
|
||||
size: 7,
|
||||
http_only: false,
|
||||
secure: true,
|
||||
session: false,
|
||||
same_site: Some("Strict".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&cookie).unwrap();
|
||||
assert_eq!(json["name"], "test");
|
||||
assert_eq!(json["httpOnly"], false);
|
||||
assert_eq!(json["secure"], true);
|
||||
assert_eq!(json["sameSite"], "Strict");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_routes_state_list() {
|
||||
let cmd = serde_json::json!({ "action": "state_list" });
|
||||
let result = dispatch_state_command(&cmd);
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_returns_none_for_unknown() {
|
||||
let cmd = serde_json::json!({ "action": "navigate" });
|
||||
assert!(dispatch_state_command(&cmd).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_returns_none_for_missing_action() {
|
||||
let cmd = serde_json::json!({});
|
||||
assert!(dispatch_state_command(&cmd).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_show_missing_path() {
|
||||
let cmd = serde_json::json!({ "action": "state_show" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_rename_missing_params() {
|
||||
let cmd = serde_json::json!({ "action": "state_rename" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
|
||||
|
||||
let cmd = serde_json::json!({ "action": "state_rename", "path": "/tmp/test.json" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'name' parameter");
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
//! Stealth anti-detection module.
|
||||
//!
|
||||
//! Injects browser-level patches to evade bot detection (creepjs, sannysoft,
|
||||
//! Cloudflare Turnstile, etc.) by normalizing fingerprint signals that betray
|
||||
//! headless or automated Chrome instances.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
/// Full stealth JS payload compiled at build time (for --launch mode).
|
||||
const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js");
|
||||
|
||||
/// Minimal stealth script for CDP-attach mode (connecting to user's real Chrome).
|
||||
/// Only removes navigator.webdriver — the browser's own fingerprint is already real.
|
||||
/// Minimal stealth script for CDP-attach mode.
|
||||
/// Emulation.setAutomationOverride handles navigator.webdriver at the native
|
||||
/// level, so no JS patching is needed in CdpAttach mode. An empty script
|
||||
/// avoids creating any detectable lie-props artifacts.
|
||||
const MINIMAL_STEALTH_SCRIPT: &str = "";
|
||||
|
||||
/// Chrome launch arguments that reduce automation fingerprint surface.
|
||||
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=default",
|
||||
];
|
||||
|
||||
/// Connection mode determines which stealth patches to apply.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum StealthMode {
|
||||
/// Connected to user's real Chrome — minimal patches only (webdriver removal).
|
||||
/// The browser already has a real fingerprint; heavy patches would create detectable lies.
|
||||
CdpAttach,
|
||||
/// Launched a new Chrome instance — apply full stealth patches.
|
||||
FullLaunch,
|
||||
}
|
||||
|
||||
/// Build the stealth JS payload for the given mode and locale.
|
||||
pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
if mode == StealthMode::CdpAttach {
|
||||
return MINIMAL_STEALTH_SCRIPT.to_string();
|
||||
}
|
||||
|
||||
// Full launch mode: inject all patches
|
||||
let locale = locale.unwrap_or("en-US");
|
||||
let base_lang = locale.split('-').next().unwrap_or(locale);
|
||||
let languages: Vec<&str> = if base_lang == locale {
|
||||
vec![locale]
|
||||
} else {
|
||||
vec![locale, base_lang]
|
||||
};
|
||||
let config_line = format!(
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false }};"#,
|
||||
locale,
|
||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||
);
|
||||
|
||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
|
||||
) {
|
||||
format!("{}{}", config_line, rest)
|
||||
} else {
|
||||
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply stealth patches to a browser session.
|
||||
///
|
||||
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
|
||||
/// In `FullLaunch` mode (new Chrome): injects all 32 patches + UA override.
|
||||
pub async fn apply_stealth(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// First: disable the automation flag at the CDP protocol level.
|
||||
// This tells Chrome to natively set navigator.webdriver = false,
|
||||
// which is undetectable by lie-detection systems like CreepJS.
|
||||
// Falls back gracefully on older Chrome versions that don't support this.
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Emulation.setAutomationOverride",
|
||||
Some(json!({ "enabled": false })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
let script = build_stealth_script(mode, locale);
|
||||
|
||||
// Inject stealth scripts to run before page JS
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "source": script })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// In full launch mode, also override UA to remove HeadlessChrome marker
|
||||
if mode == StealthMode::FullLaunch {
|
||||
let ua = get_browser_user_agent(client, session_id).await;
|
||||
if let Some(ua) = ua {
|
||||
let cleaned = ua.replace("HeadlessChrome", "Chrome");
|
||||
if cleaned != ua {
|
||||
client
|
||||
.send_command(
|
||||
"Emulation.setUserAgentOverride",
|
||||
Some(json!({
|
||||
"userAgent": cleaned,
|
||||
"acceptLanguage": locale.unwrap_or("en-US"),
|
||||
"platform": platform_string(),
|
||||
"userAgentMetadata": build_ua_metadata(&cleaned, locale),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the browser's User-Agent string via CDP.
|
||||
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
|
||||
let result = client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(json!({ "expression": "navigator.userAgent", "returnByValue": true })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
result
|
||||
.get("result")
|
||||
.and_then(|r| r.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Also run stealth script on the current page (for already-loaded pages after CDP attach).
|
||||
pub async fn apply_stealth_to_current_page(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let script = build_stealth_script(mode, locale);
|
||||
client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(json!({
|
||||
"expression": script,
|
||||
"returnByValue": true,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Strip sourceURL comments from CDP expressions to avoid leaking
|
||||
/// automation-framework identifiers in stack traces.
|
||||
pub fn strip_source_url_labels(input: &str) -> String {
|
||||
// Remove //# sourceURL=... and //@ sourceURL=...
|
||||
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
|
||||
let output = re_line.replace_all(input, "");
|
||||
// Remove /*# sourceURL=...*/ block comments
|
||||
let re_block =
|
||||
regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
|
||||
re_block.replace_all(&output, "").to_string()
|
||||
}
|
||||
|
||||
fn platform_string() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Win32"
|
||||
} else {
|
||||
"Linux"
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_hint() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else {
|
||||
"Linux"
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_version_hint() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"14.0.0"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"10.0.0"
|
||||
} else {
|
||||
"6.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ua_metadata(ua: &str, locale: Option<&str>) -> serde_json::Value {
|
||||
// Extract Chrome version from UA string
|
||||
let chrome_version = ua
|
||||
.split("Chrome/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.unwrap_or("130.0.0.0");
|
||||
let major = chrome_version.split('.').next().unwrap_or("130");
|
||||
|
||||
let _lang = locale.unwrap_or("en-US");
|
||||
|
||||
json!({
|
||||
"brands": [
|
||||
{ "brand": "Chromium", "version": major },
|
||||
{ "brand": "Google Chrome", "version": major },
|
||||
{ "brand": "Not?A_Brand", "version": "99" },
|
||||
],
|
||||
"fullVersionList": [
|
||||
{ "brand": "Chromium", "version": chrome_version },
|
||||
{ "brand": "Google Chrome", "version": chrome_version },
|
||||
{ "brand": "Not?A_Brand", "version": "99.0.0.0" },
|
||||
],
|
||||
"fullVersion": chrome_version,
|
||||
"platform": platform_hint(),
|
||||
"platformVersion": platform_version_hint(),
|
||||
"architecture": if cfg!(target_arch = "aarch64") { "arm" } else { "x86" },
|
||||
"model": "",
|
||||
"mobile": false,
|
||||
"bitness": "64",
|
||||
"wow64": false,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,94 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::EvaluateParams;
|
||||
|
||||
pub async fn storage_get(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
key: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
|
||||
if let Some(k) = key {
|
||||
let js = format!(
|
||||
"{}.getItem({})",
|
||||
st,
|
||||
serde_json::to_string(k).unwrap_or_default()
|
||||
);
|
||||
let result = eval_simple(client, session_id, &js).await?;
|
||||
Ok(json!({ "key": k, "value": result }))
|
||||
} else {
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const s = {};
|
||||
const data = {{}};
|
||||
for (let i = 0; i < s.length; i++) {{
|
||||
const key = s.key(i);
|
||||
data[key] = s.getItem(key);
|
||||
}}
|
||||
return data;
|
||||
}})()"#,
|
||||
st
|
||||
);
|
||||
let result = eval_simple(client, session_id, &js).await?;
|
||||
Ok(json!({ "data": result }))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn storage_set(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
let js = format!(
|
||||
"{}.setItem({}, {})",
|
||||
st,
|
||||
serde_json::to_string(key).unwrap_or_default(),
|
||||
serde_json::to_string(value).unwrap_or_default(),
|
||||
);
|
||||
eval_simple(client, session_id, &js).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn storage_clear(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
) -> Result<(), String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
let js = format!("{}.clear()", st);
|
||||
eval_simple(client, session_id, &js).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn storage_js_name(storage_type: &str) -> &str {
|
||||
match storage_type {
|
||||
"session" => "sessionStorage",
|
||||
_ => "localStorage",
|
||||
}
|
||||
}
|
||||
|
||||
async fn eval_simple(client: &CdpClient, session_id: &str, js: &str) -> Result<Value, String> {
|
||||
let result: super::cdp::types::EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ref details) = result.exception_details {
|
||||
return Err(format!("Storage error: {}", details.text));
|
||||
}
|
||||
|
||||
Ok(result.result.value.unwrap_or(Value::Null))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drag Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.4 sans-serif;
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
#pad {
|
||||
position: relative;
|
||||
width: 800px;
|
||||
height: 500px;
|
||||
margin: 24px;
|
||||
border: 1px solid #999;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#target {
|
||||
position: absolute;
|
||||
left: 320px;
|
||||
top: 40px;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
background: #e34c26;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#target.dragging {
|
||||
cursor: grabbing;
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
#log {
|
||||
margin: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="pad">
|
||||
<div id="target">drag me</div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const target = document.getElementById("target");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
window.__dragProbe = {
|
||||
dragging: false,
|
||||
events: [],
|
||||
finalLeft: 320,
|
||||
finalTop: 40,
|
||||
};
|
||||
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
function pushEvent(event, extra = {}) {
|
||||
window.__dragProbe.events.push({
|
||||
type: event.type,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
target: event.target.id || event.target.tagName,
|
||||
...extra,
|
||||
});
|
||||
logEl.textContent = JSON.stringify(window.__dragProbe, null, 2);
|
||||
}
|
||||
|
||||
function onPointerLikeStart(event) {
|
||||
if (event.type === "mousedown") {
|
||||
const rect = target.getBoundingClientRect();
|
||||
offsetX = event.clientX - rect.left;
|
||||
offsetY = event.clientY - rect.top;
|
||||
window.__dragProbe.dragging = true;
|
||||
target.classList.add("dragging");
|
||||
event.preventDefault();
|
||||
}
|
||||
pushEvent(event, { phase: "start" });
|
||||
}
|
||||
|
||||
target.addEventListener("mousedown", (event) => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
offsetX = event.clientX - rect.left;
|
||||
offsetY = event.clientY - rect.top;
|
||||
window.__dragProbe.dragging = true;
|
||||
target.classList.add("dragging");
|
||||
event.preventDefault();
|
||||
pushEvent(event, { phase: "start" });
|
||||
});
|
||||
target.addEventListener("pointerdown", onPointerLikeStart);
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (window.__dragProbe.dragging) {
|
||||
const left = event.clientX - offsetX;
|
||||
const top = event.clientY - offsetY;
|
||||
target.style.left = `${left}px`;
|
||||
target.style.top = `${top}px`;
|
||||
window.__dragProbe.finalLeft = left;
|
||||
window.__dragProbe.finalTop = top;
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
document.addEventListener("pointermove", (event) => {
|
||||
pushEvent(event);
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", (event) => {
|
||||
if (window.__dragProbe.dragging) {
|
||||
window.__dragProbe.dragging = false;
|
||||
target.classList.remove("dragging");
|
||||
}
|
||||
pushEvent(event, { phase: "end" });
|
||||
});
|
||||
document.addEventListener("pointerup", (event) => {
|
||||
pushEvent(event, { phase: "end" });
|
||||
});
|
||||
target.addEventListener("dragstart", (event) => {
|
||||
pushEvent(event, { phase: "dragstart" });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,91 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>HTML5 Drag Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font: 14px/1.4 sans-serif;
|
||||
}
|
||||
|
||||
#source, #dest {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #666;
|
||||
user-select: none;
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
#source {
|
||||
background: #f97316;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#dest {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="source" draggable="true">drag source</div>
|
||||
<div id="dest">drop zone</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const source = document.getElementById("source");
|
||||
const dest = document.getElementById("dest");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
window.__html5DragProbe = { events: [] };
|
||||
|
||||
function pushEvent(event, extra = {}) {
|
||||
window.__html5DragProbe.events.push({
|
||||
type: event.type,
|
||||
target: event.target.id || event.target.tagName,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
...extra,
|
||||
});
|
||||
logEl.textContent = JSON.stringify(window.__html5DragProbe, null, 2);
|
||||
}
|
||||
|
||||
for (const type of ["pointerdown", "mousedown", "dragstart", "drag", "dragend"]) {
|
||||
source.addEventListener(type, (event) => {
|
||||
if (type === "dragstart") {
|
||||
event.dataTransfer.setData("text/plain", "probe");
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
for (const type of ["pointermove", "mousemove", "dragenter", "dragover", "drop", "pointerup", "mouseup"]) {
|
||||
document.addEventListener(type, (event) => {
|
||||
if (type === "dragover") {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (type === "drop") {
|
||||
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
|
||||
return;
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
dest.addEventListener("dragover", (event) => event.preventDefault());
|
||||
dest.addEventListener("drop", (event) => {
|
||||
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,113 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pointer Capture Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font: 14px/1.4 sans-serif;
|
||||
}
|
||||
#crop {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
height: 180px;
|
||||
border: 2px solid #fff;
|
||||
outline: 1px solid #555;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
#handle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: -16px;
|
||||
left: -16px;
|
||||
padding-top: 13px;
|
||||
padding-left: 13px;
|
||||
box-sizing: content-box;
|
||||
background: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
#handle::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-top: 2px solid white;
|
||||
border-left: 2px solid white;
|
||||
}
|
||||
pre {
|
||||
margin-top: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="crop" aria-label="crop area">
|
||||
<div id="handle" aria-label="crop handle topLeft" data-anchor="topLeft"></div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const crop = document.getElementById("crop");
|
||||
const handle = document.getElementById("handle");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
const state = {
|
||||
targetAnchor: null,
|
||||
dragging: false,
|
||||
moved: false,
|
||||
events: [],
|
||||
};
|
||||
window.__pointerCaptureProbe = state;
|
||||
|
||||
function sync() {
|
||||
logEl.textContent = JSON.stringify(state, null, 2);
|
||||
}
|
||||
|
||||
function push(event, extra = {}) {
|
||||
state.events.push({
|
||||
type: event.type,
|
||||
target: event.target.id || event.target.tagName,
|
||||
currentTarget: event.currentTarget.id || event.currentTarget.tagName,
|
||||
pointerId: event.pointerId,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
hasCapture: event.currentTarget.hasPointerCapture?.(event.pointerId) ?? false,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
...extra,
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
crop.addEventListener("pointerdown", (event) => {
|
||||
state.targetAnchor = event.target.getAttribute("data-anchor");
|
||||
crop.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
push(event, { phase: "down", targetAnchor: state.targetAnchor });
|
||||
});
|
||||
|
||||
crop.addEventListener("pointermove", (event) => {
|
||||
const hasCapture = crop.hasPointerCapture(event.pointerId);
|
||||
if (hasCapture && state.targetAnchor) {
|
||||
state.dragging = true;
|
||||
state.moved = true;
|
||||
}
|
||||
push(event, { phase: hasCapture ? "drag" : "hover", targetAnchor: state.targetAnchor });
|
||||
});
|
||||
|
||||
crop.addEventListener("pointerup", (event) => {
|
||||
const hadCapture = crop.hasPointerCapture(event.pointerId);
|
||||
state.dragging = false;
|
||||
push(event, { phase: "up", targetAnchor: state.targetAnchor, hadCapture });
|
||||
state.targetAnchor = null;
|
||||
});
|
||||
|
||||
handle.addEventListener("pointerdown", (event) => push(event, { listener: "handle" }));
|
||||
handle.addEventListener("pointermove", (event) => push(event, { listener: "handle" }));
|
||||
handle.addEventListener("pointerup", (event) => push(event, { listener: "handle" }));
|
||||
|
||||
sync();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,373 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
const MAX_PROFILE_EVENTS: usize = 5_000_000;
|
||||
|
||||
const DEFAULT_PROFILER_CATEGORIES: &[&str] = &[
|
||||
"devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.frame",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"v8.execute",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
"disabled-by-default-v8.cpu_profiler.hires",
|
||||
"v8",
|
||||
"disabled-by-default-v8.runtime_stats",
|
||||
"blink",
|
||||
"blink.user_timing",
|
||||
"latencyInfo",
|
||||
"renderer.scheduler",
|
||||
"sequence_manager",
|
||||
"toplevel",
|
||||
];
|
||||
|
||||
pub struct TracingState {
|
||||
pub active: bool,
|
||||
pub events: Vec<Value>,
|
||||
pub events_dropped: bool,
|
||||
}
|
||||
|
||||
impl TracingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
events: Vec::new(),
|
||||
events_dropped: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn trace_start(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
) -> Result<Value, String> {
|
||||
if tracing_state.active {
|
||||
return Err("Tracing already active".to_string());
|
||||
}
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Tracing.start",
|
||||
Some(json!({
|
||||
"traceConfig": {
|
||||
"recordMode": "recordContinuously",
|
||||
},
|
||||
"transferMode": "ReturnAsStream",
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing_state.active = true;
|
||||
tracing_state.events.clear();
|
||||
tracing_state.events_dropped = false;
|
||||
|
||||
Ok(json!({ "started": true }))
|
||||
}
|
||||
|
||||
pub async fn trace_stop(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
path: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
if !tracing_state.active {
|
||||
return Err("No tracing in progress".to_string());
|
||||
}
|
||||
|
||||
// Subscribe to events before stopping
|
||||
let mut rx = client.subscribe();
|
||||
|
||||
client
|
||||
.send_command_no_params("Tracing.end", Some(session_id))
|
||||
.await?;
|
||||
|
||||
// Collect trace data with timeout
|
||||
let mut trace_events: Vec<Value> = Vec::new();
|
||||
let mut stream_handle: Option<String> = None;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => {
|
||||
if event.session_id.as_deref() != Some(session_id) {
|
||||
continue;
|
||||
}
|
||||
match event.method.as_str() {
|
||||
"Tracing.dataCollected" => {
|
||||
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(arr.iter().cloned());
|
||||
}
|
||||
}
|
||||
"Tracing.tracingComplete" => {
|
||||
stream_handle = event
|
||||
.params
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => {
|
||||
return Err("Tracing stop timed out after 30s".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If ReturnAsStream mode was used, read trace data from the IO stream
|
||||
if let Some(handle) = stream_handle {
|
||||
if trace_events.is_empty() {
|
||||
let stream_data = read_io_stream(client, session_id, &handle).await?;
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(&stream_data) {
|
||||
if let Some(events) = parsed.get("traceEvents").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(events.iter().cloned());
|
||||
}
|
||||
} else {
|
||||
// Try parsing as newline-delimited JSON
|
||||
for line in stream_data.lines() {
|
||||
if let Ok(val) = serde_json::from_str::<Value>(line) {
|
||||
if let Some(events) = val.get("traceEvents").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(events.iter().cloned());
|
||||
} else {
|
||||
trace_events.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Close the IO stream
|
||||
let _ = client
|
||||
.send_command(
|
||||
"IO.close",
|
||||
Some(json!({ "handle": handle })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing_state.active = false;
|
||||
|
||||
let save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_traces_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
dir.join(format!("trace-{}.json", timestamp))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let trace_json = json!({ "traceEvents": trace_events });
|
||||
let json_str = serde_json::to_string(&trace_json)
|
||||
.map_err(|e| format!("Failed to serialize trace: {}", e))?;
|
||||
std::fs::write(&save_path, json_str)
|
||||
.map_err(|e| format!("Failed to write trace to {}: {}", save_path, e))?;
|
||||
|
||||
Ok(json!({ "path": save_path, "eventCount": trace_events.len() }))
|
||||
}
|
||||
|
||||
pub async fn profiler_start(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
categories: Option<Vec<String>>,
|
||||
) -> Result<Value, String> {
|
||||
if tracing_state.active {
|
||||
return Err("Profiling/tracing already active".to_string());
|
||||
}
|
||||
|
||||
let cats: Vec<String> = categories.unwrap_or_else(|| {
|
||||
DEFAULT_PROFILER_CATEGORIES
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
});
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Tracing.start",
|
||||
Some(json!({
|
||||
"traceConfig": {
|
||||
"includedCategories": cats,
|
||||
"enableSampling": true,
|
||||
},
|
||||
"transferMode": "ReportEvents",
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing_state.active = true;
|
||||
tracing_state.events.clear();
|
||||
tracing_state.events_dropped = false;
|
||||
|
||||
Ok(json!({ "started": true }))
|
||||
}
|
||||
|
||||
pub async fn profiler_stop(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
path: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
if !tracing_state.active {
|
||||
return Err("No profiling in progress".to_string());
|
||||
}
|
||||
|
||||
let mut rx = client.subscribe();
|
||||
|
||||
client
|
||||
.send_command_no_params("Tracing.end", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let mut events: Vec<Value> = Vec::new();
|
||||
let mut dropped = false;
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => {
|
||||
if event.session_id.as_deref() != Some(session_id) {
|
||||
continue;
|
||||
}
|
||||
match event.method.as_str() {
|
||||
"Tracing.dataCollected" => {
|
||||
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
|
||||
if events.len() + arr.len() > MAX_PROFILE_EVENTS {
|
||||
dropped = true;
|
||||
} else {
|
||||
events.extend(arr.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
"Tracing.tracingComplete" => {
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => {
|
||||
return Err("Profiler stop timed out after 30s".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing_state.active = false;
|
||||
|
||||
let save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_profiles_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
dir.join(format!("profile-{}.json", timestamp))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let clock_domain = get_clock_domain();
|
||||
let mut profile = json!({ "traceEvents": events });
|
||||
if let Some(cd) = clock_domain {
|
||||
profile
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("metadata".to_string(), json!({ "clock-domain": cd }));
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
std::fs::write(&save_path, json_str)
|
||||
.map_err(|e| format!("Failed to write profile to {}: {}", save_path, e))?;
|
||||
|
||||
let event_count = events.len();
|
||||
let mut result = json!({ "path": save_path, "eventCount": event_count });
|
||||
if dropped {
|
||||
result.as_object_mut().unwrap().insert(
|
||||
"warning".to_string(),
|
||||
Value::String(format!(
|
||||
"Events exceeded {} limit; some dropped",
|
||||
MAX_PROFILE_EVENTS
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Read all data from a CDP IO stream handle.
|
||||
async fn read_io_stream(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
handle: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut data = String::new();
|
||||
loop {
|
||||
let result = client
|
||||
.send_command(
|
||||
"IO.read",
|
||||
Some(json!({
|
||||
"handle": handle,
|
||||
"size": 1024 * 1024,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(chunk) = result.get("data").and_then(|v| v.as_str()) {
|
||||
data.push_str(chunk);
|
||||
}
|
||||
|
||||
let eof = result.get("eof").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
if eof {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn get_clock_domain() -> Option<&'static str> {
|
||||
if cfg!(target_os = "linux") {
|
||||
Some("LINUX_CLOCK_MONOTONIC")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Some("MAC_MACH_ABSOLUTE_TIME")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_traces_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("traces")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("traces")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profiles_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("profiles")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("profiles")
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::client::WebDriverClient;
|
||||
|
||||
const APPIUM_DEFAULT_PORT: u16 = 4723;
|
||||
const APPIUM_STARTUP_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
pub struct AppiumManager {
|
||||
pub client: WebDriverClient,
|
||||
appium_process: Option<Child>,
|
||||
pub device_udid: Option<String>,
|
||||
}
|
||||
|
||||
impl AppiumManager {
|
||||
pub async fn connect_or_launch(device_udid: Option<&str>) -> Result<Self, String> {
|
||||
let port = APPIUM_DEFAULT_PORT;
|
||||
let client = WebDriverClient::new(port);
|
||||
|
||||
// Check if Appium is already running
|
||||
if is_appium_running(port).await {
|
||||
return Ok(Self {
|
||||
client,
|
||||
appium_process: None,
|
||||
device_udid: device_udid.map(String::from),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to launch Appium
|
||||
let appium_process = launch_appium(port)?;
|
||||
|
||||
// Wait for Appium to be ready
|
||||
wait_for_appium(port, APPIUM_STARTUP_TIMEOUT_SECS).await?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
appium_process: Some(appium_process),
|
||||
device_udid: device_udid.map(String::from),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_ios_capabilities(
|
||||
device_udid: Option<&str>,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Value {
|
||||
let mut caps = json!({
|
||||
"platformName": "iOS",
|
||||
"appium:automationName": "XCUITest",
|
||||
"browserName": "Safari",
|
||||
"appium:noReset": true,
|
||||
});
|
||||
|
||||
if let Some(name) = device_name {
|
||||
caps["appium:deviceName"] = json!(name);
|
||||
} else {
|
||||
caps["appium:deviceName"] = json!("iPhone");
|
||||
}
|
||||
|
||||
if let Some(ver) = platform_version {
|
||||
caps["appium:platformVersion"] = json!(ver);
|
||||
}
|
||||
|
||||
if let Some(udid) = device_udid {
|
||||
caps["appium:udid"] = json!(udid);
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
|
||||
pub async fn create_ios_session(
|
||||
&mut self,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let caps = Self::build_ios_capabilities(
|
||||
self.device_udid.as_deref(),
|
||||
device_name,
|
||||
platform_version,
|
||||
);
|
||||
self.client.create_session(caps).await
|
||||
}
|
||||
|
||||
pub async fn tap(&self, x: f64, y: f64) -> Result<(), String> {
|
||||
let sid = self
|
||||
.client
|
||||
.session_id_pub()
|
||||
.ok_or("No active session")?
|
||||
.to_string();
|
||||
let actions = json!({
|
||||
"actions": [{
|
||||
"type": "pointer",
|
||||
"id": "finger1",
|
||||
"parameters": { "pointerType": "touch" },
|
||||
"actions": [
|
||||
{ "type": "pointerMove", "duration": 0, "x": x as i64, "y": y as i64 },
|
||||
{ "type": "pointerDown", "button": 0 },
|
||||
{ "type": "pause", "duration": 100 },
|
||||
{ "type": "pointerUp", "button": 0 },
|
||||
]
|
||||
}]
|
||||
});
|
||||
self.client.execute_actions(&sid, &actions).await
|
||||
}
|
||||
|
||||
pub async fn swipe(
|
||||
&self,
|
||||
start_x: f64,
|
||||
start_y: f64,
|
||||
end_x: f64,
|
||||
end_y: f64,
|
||||
duration_ms: u64,
|
||||
) -> Result<(), String> {
|
||||
let sid = self
|
||||
.client
|
||||
.session_id_pub()
|
||||
.ok_or("No active session")?
|
||||
.to_string();
|
||||
let actions = json!({
|
||||
"actions": [{
|
||||
"type": "pointer",
|
||||
"id": "finger1",
|
||||
"parameters": { "pointerType": "touch" },
|
||||
"actions": [
|
||||
{ "type": "pointerMove", "duration": 0, "x": start_x as i64, "y": start_y as i64 },
|
||||
{ "type": "pointerDown", "button": 0 },
|
||||
{ "type": "pointerMove", "duration": duration_ms, "x": end_x as i64, "y": end_y as i64 },
|
||||
{ "type": "pointerUp", "button": 0 },
|
||||
]
|
||||
}]
|
||||
});
|
||||
self.client.execute_actions(&sid, &actions).await
|
||||
}
|
||||
|
||||
pub async fn close(&mut self) -> Result<(), String> {
|
||||
let _ = self.client.delete_session().await;
|
||||
if let Some(ref mut child) = self.appium_process {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AppiumManager {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref mut child) = self.appium_process {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_appium_running(port: u16) -> bool {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
.map(|r| r.is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn launch_appium(port: u16) -> Result<Child, String> {
|
||||
// Try npx appium first, then direct appium
|
||||
let result = Command::new("npx")
|
||||
.args(["appium", "--relaxed-security", "--port", &port.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
match result {
|
||||
Ok(child) => Ok(child),
|
||||
Err(_) => Command::new("appium")
|
||||
.args(["--relaxed-security", "--port", &port.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to launch Appium. Install it with: npm install -g appium. Error: {}",
|
||||
e
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_appium(port: u16, timeout_secs: u64) -> Result<(), String> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
return Err("Timeout waiting for Appium to start".to_string());
|
||||
}
|
||||
if is_appium_running(port).await {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_appium_constants() {
|
||||
assert_eq!(APPIUM_DEFAULT_PORT, 4723);
|
||||
assert_eq!(APPIUM_STARTUP_TIMEOUT_SECS, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ios_capabilities_use_vendor_prefix() {
|
||||
let caps = AppiumManager::build_ios_capabilities(
|
||||
Some("TEST-UDID-123"),
|
||||
Some("iPhone 16 Pro"),
|
||||
Some("18.5"),
|
||||
);
|
||||
|
||||
// W3C standard capabilities must NOT have vendor prefix
|
||||
assert!(caps.get("platformName").is_some());
|
||||
assert!(caps.get("browserName").is_some());
|
||||
|
||||
// Non-standard capabilities MUST have appium: vendor prefix
|
||||
assert!(caps.get("appium:automationName").is_some());
|
||||
assert!(caps.get("appium:noReset").is_some());
|
||||
assert!(caps.get("appium:deviceName").is_some());
|
||||
assert!(caps.get("appium:platformVersion").is_some());
|
||||
assert!(caps.get("appium:udid").is_some());
|
||||
|
||||
// Must NOT have unprefixed non-standard capabilities
|
||||
assert!(caps.get("automationName").is_none());
|
||||
assert!(caps.get("noReset").is_none());
|
||||
assert!(caps.get("deviceName").is_none());
|
||||
assert!(caps.get("udid").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Abstract backend for browser automation. CDP (Chromium) and WebDriver
|
||||
/// (Safari/iOS) share this interface so actions.rs can remain backend-agnostic
|
||||
/// in the future.
|
||||
#[async_trait]
|
||||
pub trait BrowserBackend: Send + Sync {
|
||||
async fn navigate(&self, url: &str) -> Result<(), String>;
|
||||
async fn get_url(&self) -> Result<String, String>;
|
||||
async fn get_title(&self) -> Result<String, String>;
|
||||
async fn get_content(&self) -> Result<String, String>;
|
||||
async fn evaluate(&self, script: &str) -> Result<Value, String>;
|
||||
async fn screenshot(&self) -> Result<String, String>;
|
||||
async fn click(&self, selector: &str) -> Result<(), String>;
|
||||
async fn fill(&self, selector: &str, value: &str) -> Result<(), String>;
|
||||
async fn close(&mut self) -> Result<(), String>;
|
||||
async fn back(&self) -> Result<(), String>;
|
||||
async fn forward(&self) -> Result<(), String>;
|
||||
async fn reload(&self) -> Result<(), String>;
|
||||
async fn get_cookies(&self) -> Result<Value, String>;
|
||||
fn backend_type(&self) -> &str;
|
||||
|
||||
fn supports(&self, feature: &str) -> bool {
|
||||
match feature {
|
||||
"navigate" | "evaluate" | "screenshot" | "click" | "fill" => true,
|
||||
"screencast" | "tracing" | "network_intercept" | "cdp" => self.backend_type() == "cdp",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_error(&self, action: &str) -> String {
|
||||
format!(
|
||||
"Action '{}' is not supported on the {} backend",
|
||||
action,
|
||||
self.backend_type()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// WebDriver implementation of BrowserBackend
|
||||
pub struct WebDriverBackend {
|
||||
client: super::client::WebDriverClient,
|
||||
}
|
||||
|
||||
impl WebDriverBackend {
|
||||
pub fn new(client: super::client::WebDriverClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BrowserBackend for WebDriverBackend {
|
||||
async fn navigate(&self, url: &str) -> Result<(), String> {
|
||||
self.client.navigate(url).await
|
||||
}
|
||||
|
||||
async fn get_url(&self) -> Result<String, String> {
|
||||
self.client.get_url().await
|
||||
}
|
||||
|
||||
async fn get_title(&self) -> Result<String, String> {
|
||||
self.client.get_title().await
|
||||
}
|
||||
|
||||
async fn get_content(&self) -> Result<String, String> {
|
||||
self.client.get_page_source().await
|
||||
}
|
||||
|
||||
async fn evaluate(&self, script: &str) -> Result<Value, String> {
|
||||
self.client.execute_script(script, vec![]).await
|
||||
}
|
||||
|
||||
async fn screenshot(&self) -> Result<String, String> {
|
||||
self.client.screenshot().await
|
||||
}
|
||||
|
||||
async fn click(&self, selector: &str) -> Result<(), String> {
|
||||
let element_id = self.client.find_element("css selector", selector).await?;
|
||||
self.client.click_element(&element_id).await
|
||||
}
|
||||
|
||||
async fn fill(&self, selector: &str, value: &str) -> Result<(), String> {
|
||||
let element_id = self.client.find_element("css selector", selector).await?;
|
||||
self.client.clear_element(&element_id).await?;
|
||||
self.client.send_keys(&element_id, value).await
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> Result<(), String> {
|
||||
self.client.delete_session().await
|
||||
}
|
||||
|
||||
async fn back(&self) -> Result<(), String> {
|
||||
self.client.back().await
|
||||
}
|
||||
|
||||
async fn forward(&self) -> Result<(), String> {
|
||||
self.client.forward().await
|
||||
}
|
||||
|
||||
async fn reload(&self) -> Result<(), String> {
|
||||
self.client.refresh().await
|
||||
}
|
||||
|
||||
async fn get_cookies(&self) -> Result<Value, String> {
|
||||
self.client.get_cookies().await
|
||||
}
|
||||
|
||||
fn backend_type(&self) -> &str {
|
||||
"webdriver"
|
||||
}
|
||||
}
|
||||
|
||||
/// CDP-backed backend constants for unsupported actions on WebDriver
|
||||
pub const WEBDRIVER_UNSUPPORTED_ACTIONS: &[&str] = &[
|
||||
"screencast_start",
|
||||
"screencast_stop",
|
||||
"trace_start",
|
||||
"trace_stop",
|
||||
"profiler_start",
|
||||
"profiler_stop",
|
||||
"route",
|
||||
"unroute",
|
||||
"expose",
|
||||
"addscript",
|
||||
"addinitscript",
|
||||
"network",
|
||||
"har_start",
|
||||
"har_stop",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_actions() {
|
||||
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"screencast_start"));
|
||||
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"trace_start"));
|
||||
assert!(!WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"navigate"));
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct WebDriverClient {
|
||||
base_url: String,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl WebDriverClient {
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session(&mut self, capabilities: Value) -> Result<Value, String> {
|
||||
let body = json!({
|
||||
"capabilities": {
|
||||
"alwaysMatch": capabilities,
|
||||
}
|
||||
});
|
||||
|
||||
let response = self.post("/session", &body).await?;
|
||||
|
||||
let session_id = response
|
||||
.get("value")
|
||||
.and_then(|v| v.get("sessionId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No sessionId in response")?
|
||||
.to_string();
|
||||
|
||||
self.session_id = Some(session_id);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn delete_session(&mut self) -> Result<(), String> {
|
||||
if let Some(ref sid) = self.session_id.clone() {
|
||||
let _ = self.delete(&format!("/session/{}", sid)).await;
|
||||
self.session_id = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn navigate(&self, url: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/url", sid), &json!({ "url": url }))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_url(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/url", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_title(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/title", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn find_element(&self, using: &str, value: &str) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self
|
||||
.post(
|
||||
&format!("/session/{}/element", sid),
|
||||
&json!({ "using": using, "value": value }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let element_value = response.get("value").ok_or("No element in response")?;
|
||||
|
||||
element_value
|
||||
.get("element-6066-11e4-a52e-4f735466cecf")
|
||||
.or_else(|| element_value.get("ELEMENT"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or("No element ID in response".to_string())
|
||||
}
|
||||
|
||||
pub async fn click_element(&self, element_id: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/click", sid, element_id),
|
||||
&json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_keys(&self, element_id: &str, text: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/value", sid, element_id),
|
||||
&json!({ "text": text }),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_element(&self, element_id: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/clear", sid, element_id),
|
||||
&json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_script(&self, script: &str, args: Vec<Value>) -> Result<Value, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self
|
||||
.post(
|
||||
&format!("/session/{}/execute/sync", sid),
|
||||
&json!({ "script": script, "args": args }),
|
||||
)
|
||||
.await?;
|
||||
Ok(response.get("value").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn screenshot(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/screenshot", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_cookies(&self) -> Result<Value, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/cookie", sid)).await?;
|
||||
Ok(response.get("value").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn get_page_source(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/source", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn back(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/back", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn forward(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/forward", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/refresh", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_id_pub(&self) -> Option<&str> {
|
||||
self.session_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn new_with_session(port: u16, session_id: String) -> Self {
|
||||
Self {
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
session_id: Some(session_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_actions(&self, session_id: &str, actions: &Value) -> Result<(), String> {
|
||||
self.post(&format!("/session/{}/actions", session_id), actions)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn session_id(&self) -> Result<&str, String> {
|
||||
self.session_id
|
||||
.as_deref()
|
||||
.ok_or("No active WebDriver session".to_string())
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<Value, String> {
|
||||
http_request("GET", &format!("{}{}", self.base_url, path), None).await
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: &Value) -> Result<Value, String> {
|
||||
http_request("POST", &format!("{}{}", self.base_url, path), Some(body)).await
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<Value, String> {
|
||||
http_request("DELETE", &format!("{}{}", self.base_url, path), None).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result<Value, String> {
|
||||
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port().unwrap_or(80);
|
||||
let path = parsed.path();
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let stream = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("Connection timeout: {}", addr))?
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let body_str = body
|
||||
.map(|b| serde_json::to_string(b).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let request = if body.is_some() {
|
||||
format!(
|
||||
"{} {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
method, path, addr, body_str.len(), body_str
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
|
||||
method, path, addr
|
||||
)
|
||||
};
|
||||
|
||||
let mut stream = stream;
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Write failed: {}", e))?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| format!("Read failed: {}", e))?;
|
||||
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
let body_part = response_str.split("\r\n\r\n").nth(1).unwrap_or("").trim();
|
||||
|
||||
// Handle chunked encoding
|
||||
let json_body = if body_part.contains('\n')
|
||||
&& body_part
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_hexdigit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Chunked: skip chunk size lines
|
||||
body_part
|
||||
.lines()
|
||||
.filter(|l| !l.chars().all(|c| c.is_ascii_hexdigit() || c == '\r'))
|
||||
.collect::<Vec<&str>>()
|
||||
.join("")
|
||||
} else {
|
||||
body_part.to_string()
|
||||
};
|
||||
|
||||
if json_body.is_empty() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
|
||||
serde_json::from_str(&json_body).map_err(|e| {
|
||||
format!(
|
||||
"Invalid JSON response: {} (body: {})",
|
||||
e,
|
||||
json_body.chars().take(100).collect::<String>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_new() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:4444");
|
||||
assert!(client.session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_none() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
let result = client.session_id();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No active WebDriver session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_custom_port() {
|
||||
let client = WebDriverClient::new(9515);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:9515");
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IosDevice {
|
||||
pub name: String,
|
||||
pub udid: String,
|
||||
pub state: String,
|
||||
pub runtime: String,
|
||||
pub is_real: bool,
|
||||
}
|
||||
|
||||
pub fn list_simulators() -> Result<Vec<IosDevice>, String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "list", "devices", "--json"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run xcrun simctl: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("xcrun simctl failed. Xcode may not be installed.".to_string());
|
||||
}
|
||||
|
||||
let json_str = String::from_utf8_lossy(&output.stdout);
|
||||
let parsed: Value =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Failed to parse simctl: {}", e))?;
|
||||
|
||||
let mut devices = Vec::new();
|
||||
if let Some(device_map) = parsed.get("devices").and_then(|v| v.as_object()) {
|
||||
for (runtime, device_list) in device_map {
|
||||
if let Some(arr) = device_list.as_array() {
|
||||
for device in arr {
|
||||
let name = device
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let udid = device
|
||||
.get("udid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let state = device
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
devices.push(IosDevice {
|
||||
name,
|
||||
udid,
|
||||
state,
|
||||
runtime: runtime.clone(),
|
||||
is_real: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
pub fn list_real_devices() -> Result<Vec<IosDevice>, String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["xctrace", "list", "devices"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run xcrun xctrace: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut devices = Vec::new();
|
||||
let mut in_devices = false;
|
||||
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("== Devices ==") {
|
||||
in_devices = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("== Simulators ==") {
|
||||
break;
|
||||
}
|
||||
if !in_devices || trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Format: "Device Name (OS Version) (UDID)"
|
||||
if let Some(udid_start) = trimmed.rfind('(') {
|
||||
let udid_end = trimmed.len() - 1;
|
||||
let udid = &trimmed[udid_start + 1..udid_end];
|
||||
// Validate it looks like a UDID (contains hyphens)
|
||||
if udid.contains('-') && udid.len() > 20 {
|
||||
let name_part = trimmed[..udid_start].trim();
|
||||
let name = if let Some(paren_pos) = name_part.rfind('(') {
|
||||
name_part[..paren_pos].trim().to_string()
|
||||
} else {
|
||||
name_part.to_string()
|
||||
};
|
||||
devices.push(IosDevice {
|
||||
name,
|
||||
udid: udid.to_string(),
|
||||
state: "Connected".to_string(),
|
||||
runtime: String::new(),
|
||||
is_real: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
pub fn list_all_devices() -> Result<Vec<IosDevice>, String> {
|
||||
let mut all = list_simulators().unwrap_or_default();
|
||||
all.extend(list_real_devices().unwrap_or_default());
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn boot_simulator(udid: &str) -> Result<(), String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "boot", udid])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to boot simulator: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("current state: Booted") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Failed to boot simulator {}: {}", udid, stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shutdown_simulator(udid: &str) -> Result<(), String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "shutdown", udid])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to shutdown simulator: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("current state: Shutdown") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Failed to shutdown simulator {}: {}", udid, stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn select_device(device_name: Option<&str>, udid: Option<&str>) -> Result<IosDevice, String> {
|
||||
if let Some(u) = udid {
|
||||
let devices = list_all_devices()?;
|
||||
return devices
|
||||
.into_iter()
|
||||
.find(|d| d.udid == u)
|
||||
.ok_or_else(|| format!("Device with UDID '{}' not found", u));
|
||||
}
|
||||
|
||||
if let Some(name) = device_name {
|
||||
let devices = list_all_devices()?;
|
||||
return devices
|
||||
.into_iter()
|
||||
.find(|d| d.name.to_lowercase().contains(&name.to_lowercase()))
|
||||
.ok_or_else(|| format!("Device '{}' not found", name));
|
||||
}
|
||||
|
||||
// Default: prefer most recent iPhone, prefer Pro
|
||||
let devices = list_simulators()?;
|
||||
let iphone_devices: Vec<&IosDevice> = devices
|
||||
.iter()
|
||||
.filter(|d| d.name.starts_with("iPhone"))
|
||||
.collect();
|
||||
|
||||
if iphone_devices.is_empty() {
|
||||
return devices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or("No iOS simulators found".to_string());
|
||||
}
|
||||
|
||||
// Prefer Pro models
|
||||
if let Some(pro) = iphone_devices.iter().find(|d| d.name.contains("Pro")) {
|
||||
return Ok((*pro).clone());
|
||||
}
|
||||
|
||||
Ok((*iphone_devices.last().unwrap()).clone())
|
||||
}
|
||||
|
||||
pub fn to_device_json(devices: &[IosDevice]) -> Value {
|
||||
let list: Vec<Value> = devices
|
||||
.iter()
|
||||
.map(|d| {
|
||||
json!({
|
||||
"name": d.name,
|
||||
"udid": d.udid,
|
||||
"state": d.state,
|
||||
"runtime": d.runtime,
|
||||
"isReal": d.is_real,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "devices": list })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ios_device_struct() {
|
||||
let device = IosDevice {
|
||||
name: "iPhone 15 Pro".to_string(),
|
||||
udid: "ABC-123".to_string(),
|
||||
state: "Booted".to_string(),
|
||||
runtime: "iOS-17-0".to_string(),
|
||||
is_real: false,
|
||||
};
|
||||
assert_eq!(device.name, "iPhone 15 Pro");
|
||||
assert!(!device.is_real);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_device_json() {
|
||||
let devices = vec![IosDevice {
|
||||
name: "Test".to_string(),
|
||||
udid: "123".to_string(),
|
||||
state: "Shutdown".to_string(),
|
||||
runtime: "iOS-17".to_string(),
|
||||
is_real: false,
|
||||
}];
|
||||
let json = to_device_json(&devices);
|
||||
assert!(json.get("devices").unwrap().as_array().unwrap().len() == 1);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod appium;
|
||||
pub mod backend;
|
||||
pub mod client;
|
||||
pub mod ios;
|
||||
pub mod safari;
|
||||
pub mod types;
|
||||
@@ -1,80 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct SafariDriverProcess {
|
||||
child: Child,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl SafariDriverProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SafariDriverProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_safaridriver() -> Option<PathBuf> {
|
||||
let candidates = ["/usr/bin/safaridriver"];
|
||||
|
||||
for c in &candidates {
|
||||
let p = PathBuf::from(c);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Try PATH
|
||||
if let Ok(output) = Command::new("which").arg("safaridriver").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn launch_safaridriver(port: u16) -> Result<SafariDriverProcess, String> {
|
||||
let driver_path = find_safaridriver()
|
||||
.ok_or("safaridriver not found. Safari WebDriver requires macOS with Safari.")?;
|
||||
|
||||
let child = Command::new(&driver_path)
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch safaridriver: {}", e))?;
|
||||
|
||||
// Wait for driver to be ready
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
Ok(SafariDriverProcess { child, port })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_safaridriver() {
|
||||
// Only check on macOS
|
||||
if cfg!(target_os = "macos") {
|
||||
let result = find_safaridriver();
|
||||
// Don't assert Some since it may not be enabled
|
||||
if let Some(path) = result {
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewSessionRequest {
|
||||
pub capabilities: Capabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Capabilities {
|
||||
pub always_match: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionResponse {
|
||||
pub value: SessionValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionValue {
|
||||
pub session_id: String,
|
||||
pub capabilities: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebDriverResponse {
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebDriverError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ElementResponse {
|
||||
pub value: ElementValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ElementValue {
|
||||
#[serde(rename = "element-6066-11e4-a52e-4f735466cecf")]
|
||||
pub element_id: Option<String>,
|
||||
#[serde(rename = "ELEMENT")]
|
||||
pub element_legacy: Option<String>,
|
||||
}
|
||||
|
||||
impl ElementValue {
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
self.element_id
|
||||
.as_deref()
|
||||
.or(self.element_legacy.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FindElementRequest {
|
||||
pub using: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExecuteScriptRequest {
|
||||
pub script: String,
|
||||
pub args: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieRequest {
|
||||
pub cookie: CookieData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieData {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secure: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiry: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub same_site: Option<String>,
|
||||
}
|
||||
+168
-1896
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Global mutex shared across all test modules to prevent parallel tests from
|
||||
/// interfering with each other when mutating environment variables.
|
||||
pub static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// RAII guard that locks [`ENV_MUTEX`] and restores environment variables on drop.
|
||||
pub struct EnvGuard<'a> {
|
||||
_lock: MutexGuard<'a, ()>,
|
||||
vars: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl<'a> EnvGuard<'a> {
|
||||
pub fn new(var_names: &[&str]) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
let vars = var_names
|
||||
.iter()
|
||||
.map(|&name| (name.to_string(), std::env::var(name).ok()))
|
||||
.collect();
|
||||
Self { _lock: lock, vars }
|
||||
}
|
||||
|
||||
pub fn set(&self, name: &str, value: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::set called with unregistered var: {name}"
|
||||
);
|
||||
std::env::set_var(name, value);
|
||||
}
|
||||
|
||||
pub fn remove(&self, name: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::remove called with unregistered var: {name}"
|
||||
);
|
||||
std::env::remove_var(name);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
match value {
|
||||
Some(v) => std::env::set_var(name, v),
|
||||
None => std::env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
use crate::color;
|
||||
use std::path::Path;
|
||||
use std::process::{exit, Command, Stdio};
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const NPM_REGISTRY_URL: &str = "https://registry.npmjs.org/agent-browser/latest";
|
||||
|
||||
enum InstallMethod {
|
||||
Npm,
|
||||
Pnpm,
|
||||
Yarn,
|
||||
Bun,
|
||||
Homebrew,
|
||||
Cargo,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
async fn fetch_latest_version() -> Result<String, String> {
|
||||
let resp = reqwest::get(NPM_REGISTRY_URL)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse version info: {}", e))?;
|
||||
|
||||
body.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No version field in registry response".to_string())
|
||||
}
|
||||
|
||||
/// Parse the `.install-method` marker written by postinstall.js.
|
||||
fn read_install_method_marker(exe_dir: &Path) -> Option<InstallMethod> {
|
||||
let contents = std::fs::read_to_string(exe_dir.join(".install-method")).ok()?;
|
||||
match contents.trim() {
|
||||
"npm" => Some(InstallMethod::Npm),
|
||||
"pnpm" => Some(InstallMethod::Pnpm),
|
||||
"yarn" => Some(InstallMethod::Yarn),
|
||||
"bun" => Some(InstallMethod::Bun),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_install_method() -> InstallMethod {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// Resolve symlinks to find the real binary location
|
||||
let real_path = exe.canonicalize().unwrap_or(exe);
|
||||
|
||||
// Preferred: read the marker file written at install time
|
||||
if let Some(dir) = real_path.parent() {
|
||||
if let Some(method) = read_install_method_marker(dir) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: infer from executable path
|
||||
let path_str = real_path.to_string_lossy();
|
||||
|
||||
if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
|
||||
return InstallMethod::Cargo;
|
||||
}
|
||||
|
||||
if path_str.contains("/Cellar/agent-browser/")
|
||||
|| path_str.contains("/homebrew/")
|
||||
|| path_str.contains("/linuxbrew/")
|
||||
{
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
|
||||
if path_str.contains("/pnpm/") || path_str.contains("/pnpm-global/") {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if path_str.contains("/.yarn/") || path_str.contains("/yarn/global/") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if path_str.contains("/.bun/") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if path_str.contains("node_modules/agent-browser")
|
||||
|| path_str.contains("node_modules\\agent-browser")
|
||||
{
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: probe package managers via subprocess
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
if command_succeeds("brew", &["list", "agent-browser"]) {
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
}
|
||||
|
||||
if command_output_contains(
|
||||
"pnpm",
|
||||
&["list", "-g", "agent-browser", "--depth=0"],
|
||||
"agent-browser",
|
||||
) {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if command_output_contains("yarn", &["global", "list", "--depth=0"], "agent-browser") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if command_output_contains("bun", &["pm", "ls", "-g"], "agent-browser") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if command_succeeds("npm", &["list", "-g", "agent-browser", "--depth=0"]) {
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
|
||||
InstallMethod::Unknown
|
||||
}
|
||||
|
||||
fn command_succeeds(cmd: &str, args: &[&str]) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn command_output_contains(cmd: &str, args: &[&str], needle: &str) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains(needle))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn run_upgrade_command(method: &InstallMethod) -> bool {
|
||||
let (cmd, args, display): (&str, &[&str], &str) = match method {
|
||||
InstallMethod::Npm => (
|
||||
"npm",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"npm install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Pnpm => (
|
||||
"pnpm",
|
||||
&["add", "-g", "agent-browser@latest"],
|
||||
"pnpm add -g agent-browser@latest",
|
||||
),
|
||||
// NOTE: `yarn global` is Yarn Classic (v1) only; Yarn Berry (v2+) removed it.
|
||||
// Users on Yarn v2+ won't reach this path — detection falls through to Unknown.
|
||||
InstallMethod::Yarn => (
|
||||
"yarn",
|
||||
&["global", "add", "agent-browser@latest"],
|
||||
"yarn global add agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Bun => (
|
||||
"bun",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"bun install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Homebrew => (
|
||||
"brew",
|
||||
&["upgrade", "agent-browser"],
|
||||
"brew upgrade agent-browser",
|
||||
),
|
||||
InstallMethod::Cargo => (
|
||||
"cargo",
|
||||
&["install", "agent-browser", "--force"],
|
||||
"cargo install agent-browser --force",
|
||||
),
|
||||
InstallMethod::Unknown => return false,
|
||||
};
|
||||
|
||||
println!("Running: {}", display);
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn run_upgrade() {
|
||||
let current = CURRENT_VERSION;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
});
|
||||
|
||||
let latest = match rt.block_on(fetch_latest_version()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not check latest version: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
if !latest.is_empty() && current == latest.as_str() {
|
||||
println!(
|
||||
"{} agent-browser is already at the latest version (v{})",
|
||||
color::success_indicator(),
|
||||
current
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let method = detect_install_method();
|
||||
|
||||
let method_name = match &method {
|
||||
InstallMethod::Npm => "npm",
|
||||
InstallMethod::Pnpm => "pnpm",
|
||||
InstallMethod::Yarn => "yarn",
|
||||
InstallMethod::Bun => "bun",
|
||||
InstallMethod::Homebrew => "Homebrew",
|
||||
InstallMethod::Cargo => "Cargo",
|
||||
InstallMethod::Unknown => "",
|
||||
};
|
||||
|
||||
if matches!(method, InstallMethod::Unknown) {
|
||||
eprintln!(
|
||||
"{} Could not detect installation method.",
|
||||
color::error_indicator()
|
||||
);
|
||||
eprintln!(" To update manually, run one of:");
|
||||
eprintln!(" npm install -g agent-browser@latest # npm");
|
||||
eprintln!(" pnpm add -g agent-browser@latest # pnpm");
|
||||
eprintln!(" yarn global add agent-browser@latest # yarn");
|
||||
eprintln!(" bun install -g agent-browser@latest # bun");
|
||||
eprintln!(" brew upgrade agent-browser # Homebrew");
|
||||
eprintln!(" cargo install agent-browser --force # Cargo");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
println!("Detected installation via {}.", method_name);
|
||||
|
||||
if !latest.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!(
|
||||
"Upgrading agent-browser... v{} → v{}",
|
||||
current, latest
|
||||
))
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!("Upgrading agent-browser (v{})...", current))
|
||||
);
|
||||
}
|
||||
|
||||
let success = run_upgrade_command(&method);
|
||||
|
||||
if success {
|
||||
if !latest.is_empty() {
|
||||
println!(
|
||||
"{} Done! v{} → v{}",
|
||||
color::success_indicator(),
|
||||
current,
|
||||
latest
|
||||
);
|
||||
} else {
|
||||
println!("{} Done!", color::success_indicator());
|
||||
}
|
||||
} else {
|
||||
eprintln!("{} Upgrade failed.", color::error_indicator());
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/// Check if a session name is valid (alphanumeric, hyphens, and underscores only)
|
||||
pub fn is_valid_session_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Generate error message for invalid session name
|
||||
pub fn session_name_error(name: &str) -> String {
|
||||
format!(
|
||||
"Invalid session name '{}'. Only alphanumeric characters, hyphens, and underscores are allowed.",
|
||||
name
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shiki": "^3.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Generated
+4327
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function AgentMode() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>Agent Mode</h1>
|
||||
<p>
|
||||
agent-browser works with any AI coding agent. Use <code>--json</code> for machine-readable output.
|
||||
</p>
|
||||
|
||||
<h2>Compatible agents</h2>
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Cursor</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenAI Codex</li>
|
||||
<li>Google Gemini</li>
|
||||
<li>opencode</li>
|
||||
<li>Any agent that can run shell commands</li>
|
||||
</ul>
|
||||
|
||||
<h2>JSON output</h2>
|
||||
<CodeBlock code={`agent-browser snapshot --json
|
||||
# {"success":true,"data":{"snapshot":"...","refs":{...}}}
|
||||
|
||||
agent-browser get text @e1 --json
|
||||
agent-browser is visible @e2 --json`} />
|
||||
|
||||
<h2>Optimal workflow</h2>
|
||||
<CodeBlock code={`# 1. Navigate and get snapshot
|
||||
agent-browser open example.com
|
||||
agent-browser snapshot -i --json # AI parses tree and refs
|
||||
|
||||
# 2. AI identifies target refs from snapshot
|
||||
# 3. Execute actions using refs
|
||||
agent-browser click @e2
|
||||
agent-browser fill @e3 "input text"
|
||||
|
||||
# 4. Get new snapshot if page changed
|
||||
agent-browser snapshot -i --json`} />
|
||||
|
||||
<h2>Integration</h2>
|
||||
|
||||
<h3>Just ask</h3>
|
||||
<p>The simplest approach:</p>
|
||||
<CodeBlock lang="text" code="Use agent-browser to test the login flow. Run agent-browser --help to see available commands." />
|
||||
<p>The <code>--help</code> output is comprehensive.</p>
|
||||
|
||||
<h3>AGENTS.md / CLAUDE.md</h3>
|
||||
<p>For consistent results, add to your instructions file:</p>
|
||||
<CodeBlock lang="markdown" code={`## Browser Automation
|
||||
|
||||
Use \`agent-browser\` for web automation. Run \`agent-browser --help\` for all commands.
|
||||
|
||||
Core workflow:
|
||||
1. \`agent-browser open <url>\` - Navigate to page
|
||||
2. \`agent-browser snapshot -i\` - Get interactive elements with refs (@e1, @e2)
|
||||
3. \`agent-browser click @e1\` / \`fill @e2 "text"\` - Interact using refs
|
||||
4. Re-snapshot after page changes`} />
|
||||
|
||||
<h3>Claude Code skill</h3>
|
||||
<p>For richer context:</p>
|
||||
<CodeBlock code="cp -r node_modules/agent-browser/skills/agent-browser .claude/skills/" />
|
||||
<p>Or download:</p>
|
||||
<CodeBlock code={`mkdir -p .claude/skills/agent-browser
|
||||
curl -o .claude/skills/agent-browser/SKILL.md \\
|
||||
https://raw.githubusercontent.com/vercel-labs/agent-browser/main/skills/agent-browser/SKILL.md`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function CDPMode() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>CDP Mode</h1>
|
||||
<p>Connect to an existing browser via Chrome DevTools Protocol:</p>
|
||||
<CodeBlock code={`# Start Chrome with: google-chrome --remote-debugging-port=9222
|
||||
|
||||
# Connect once, then run commands without --cdp
|
||||
agent-browser connect 9222
|
||||
agent-browser snapshot
|
||||
agent-browser tab
|
||||
agent-browser close
|
||||
|
||||
# Or pass --cdp on each command
|
||||
agent-browser --cdp 9222 snapshot`} />
|
||||
|
||||
<h2>Use cases</h2>
|
||||
<p>This enables control of:</p>
|
||||
<ul>
|
||||
<li>Electron apps</li>
|
||||
<li>Chrome/Chromium with remote debugging</li>
|
||||
<li>WebView2 applications</li>
|
||||
<li>Any browser exposing a CDP endpoint</li>
|
||||
</ul>
|
||||
|
||||
<h2>Global options</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>--session <name></code></td>
|
||||
<td>Use isolated session</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--headers <json></code></td>
|
||||
<td>HTTP headers scoped to origin</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--executable-path</code></td>
|
||||
<td>Custom browser executable</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--json</code></td>
|
||||
<td>JSON output for agents</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--full, -f</code></td>
|
||||
<td>Full page screenshot</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--name, -n</code></td>
|
||||
<td>Locator name filter</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--exact</code></td>
|
||||
<td>Exact text match</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--headed</code></td>
|
||||
<td>Show browser window</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--cdp <port></code></td>
|
||||
<td>CDP connection port</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--debug</code></td>
|
||||
<td>Debug output</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function Commands() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>Commands</h1>
|
||||
|
||||
<h2>Core</h2>
|
||||
<CodeBlock code={`agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser click <sel> # Click element
|
||||
agent-browser dblclick <sel> # Double-click
|
||||
agent-browser fill <sel> <text> # Clear and fill
|
||||
agent-browser type <sel> <text> # Type into element
|
||||
agent-browser press <key> # Press key (Enter, Tab, Control+a)
|
||||
agent-browser hover <sel> # Hover element
|
||||
agent-browser select <sel> <val> # Select dropdown option
|
||||
agent-browser check <sel> # Check checkbox
|
||||
agent-browser uncheck <sel> # Uncheck checkbox
|
||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
|
||||
agent-browser screenshot [path] # Screenshot (--full for full page)
|
||||
agent-browser snapshot # Accessibility tree with refs
|
||||
agent-browser eval <js> # Run JavaScript
|
||||
agent-browser close # Close browser`} />
|
||||
|
||||
<h2>Get info</h2>
|
||||
<CodeBlock code={`agent-browser get text <sel> # Get text content
|
||||
agent-browser get html <sel> # Get innerHTML
|
||||
agent-browser get value <sel> # Get input value
|
||||
agent-browser get attr <sel> <attr> # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get count <sel> # Count matching elements
|
||||
agent-browser get box <sel> # Get bounding box`} />
|
||||
|
||||
<h2>Check state</h2>
|
||||
<CodeBlock code={`agent-browser is visible <sel> # Check if visible
|
||||
agent-browser is enabled <sel> # Check if enabled
|
||||
agent-browser is checked <sel> # Check if checked`} />
|
||||
|
||||
<h2>Find elements</h2>
|
||||
<p>Semantic locators with actions (<code>click</code>, <code>fill</code>, <code>check</code>, <code>hover</code>, <code>text</code>):</p>
|
||||
<CodeBlock code={`agent-browser find role <role> <action> [value]
|
||||
agent-browser find text <text> <action>
|
||||
agent-browser find label <label> <action> [value]
|
||||
agent-browser find placeholder <ph> <action> [value]
|
||||
agent-browser find testid <id> <action> [value]
|
||||
agent-browser find first <sel> <action> [value]
|
||||
agent-browser find nth <n> <sel> <action> [value]`} />
|
||||
<p>Examples:</p>
|
||||
<CodeBlock code={`agent-browser find role button click --name "Submit"
|
||||
agent-browser find label "Email" fill "test@test.com"
|
||||
agent-browser find first ".item" click`} />
|
||||
|
||||
<h2>Wait</h2>
|
||||
<CodeBlock code={`agent-browser wait <selector> # Wait for element
|
||||
agent-browser wait <ms> # Wait for time
|
||||
agent-browser wait --text "Welcome" # Wait for text
|
||||
agent-browser wait --url "**/dash" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for load state
|
||||
agent-browser wait --fn "condition" # Wait for JS condition`} />
|
||||
|
||||
<h2>Mouse</h2>
|
||||
<CodeBlock code={`agent-browser mouse move <x> <y> # Move mouse
|
||||
agent-browser mouse down [button] # Press button
|
||||
agent-browser mouse up [button] # Release button
|
||||
agent-browser mouse wheel <dy> [dx] # Scroll wheel`} />
|
||||
|
||||
<h2>Settings</h2>
|
||||
<CodeBlock code={`agent-browser set viewport <w> <h> # Set viewport size
|
||||
agent-browser set device <name> # Emulate device ("iPhone 14")
|
||||
agent-browser set geo <lat> <lng> # Set geolocation
|
||||
agent-browser set offline [on|off] # Toggle offline mode
|
||||
agent-browser set headers <json> # Extra HTTP headers
|
||||
agent-browser set credentials <u> <p> # HTTP basic auth
|
||||
agent-browser set media [dark|light] # Emulate color scheme`} />
|
||||
|
||||
<h2>Cookies & storage</h2>
|
||||
<CodeBlock code={`agent-browser cookies # Get all cookies
|
||||
agent-browser cookies set <name> <val> # Set cookie
|
||||
agent-browser cookies clear # Clear cookies
|
||||
|
||||
agent-browser storage local # Get all localStorage
|
||||
agent-browser storage local <key> # Get specific key
|
||||
agent-browser storage local set <k> <v> # Set value
|
||||
agent-browser storage local clear # Clear all
|
||||
|
||||
agent-browser storage session # Same for sessionStorage`} />
|
||||
|
||||
<h2>Network</h2>
|
||||
<CodeBlock code={`agent-browser network route <url> # Intercept requests
|
||||
agent-browser network route <url> --abort # Block requests
|
||||
agent-browser network route <url> --body <json> # Mock response
|
||||
agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests`} />
|
||||
|
||||
<h2>Tabs & frames</h2>
|
||||
<CodeBlock code={`agent-browser tab # List tabs
|
||||
agent-browser tab new [url] # New tab
|
||||
agent-browser tab <n> # Switch to tab
|
||||
agent-browser tab close [n] # Close tab
|
||||
agent-browser frame <sel> # Switch to iframe
|
||||
agent-browser frame main # Back to main frame`} />
|
||||
|
||||
<h2>Debug</h2>
|
||||
<CodeBlock code={`agent-browser trace start [path] # Start trace
|
||||
agent-browser trace stop [path] # Stop and save trace
|
||||
agent-browser console # View console messages
|
||||
agent-browser errors # View page errors
|
||||
agent-browser highlight <sel> # Highlight element
|
||||
agent-browser state save <path> # Save auth state
|
||||
agent-browser state load <path> # Load auth state`} />
|
||||
|
||||
<h2>Navigation</h2>
|
||||
<CodeBlock code={`agent-browser back # Go back
|
||||
agent-browser forward # Go forward
|
||||
agent-browser reload # Reload page`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,182 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #000000;
|
||||
--foreground: #ededed;
|
||||
--muted: #888888;
|
||||
--border: #222222;
|
||||
--accent: #ededed;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-border: var(--border);
|
||||
--color-accent: var(--accent);
|
||||
--font-sans: var(--font-geist);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-geist), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre {
|
||||
background: #111 !important;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.875rem;
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
pre {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
background: #1a1a1a;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Prose */
|
||||
.prose {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.prose h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.prose ul, .prose ol {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose li strong {
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--foreground);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.prose th {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.prose td {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.prose td code {
|
||||
color: var(--foreground);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function Installation() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>Installation</h1>
|
||||
|
||||
<h2>npm (recommended)</h2>
|
||||
<CodeBlock code={`npm install -g agent-browser
|
||||
agent-browser install # Download Chromium`} />
|
||||
|
||||
<h2>From source</h2>
|
||||
<CodeBlock code={`git clone https://github.com/vercel-labs/agent-browser
|
||||
cd agent-browser
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm build:native
|
||||
./bin/agent-browser install
|
||||
pnpm link --global`} />
|
||||
|
||||
<h2>Linux dependencies</h2>
|
||||
<p>On Linux, install system dependencies:</p>
|
||||
<CodeBlock code={`agent-browser install --with-deps
|
||||
# or manually: npx playwright install-deps chromium`} />
|
||||
|
||||
<h2>Custom browser</h2>
|
||||
<p>
|
||||
Use a custom browser executable instead of bundled Chromium:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Serverless</strong> - Use <code>@sparticuz/chromium</code> (~50MB vs ~684MB)</li>
|
||||
<li><strong>System browser</strong> - Use existing Chrome installation</li>
|
||||
<li><strong>Custom builds</strong> - Use modified browser builds</li>
|
||||
</ul>
|
||||
|
||||
<CodeBlock code={`# Via flag
|
||||
agent-browser --executable-path /path/to/chromium open example.com
|
||||
|
||||
# Via environment variable
|
||||
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com`} />
|
||||
|
||||
<h3>Serverless example</h3>
|
||||
<CodeBlock lang="typescript" code={`import chromium from '@sparticuz/chromium';
|
||||
import { BrowserManager } from 'agent-browser';
|
||||
|
||||
export async function handler() {
|
||||
const browser = new BrowserManager();
|
||||
await browser.launch({
|
||||
executablePath: await chromium.executablePath(),
|
||||
headless: true,
|
||||
});
|
||||
// ... use browser
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
|
||||
const geist = Geist({
|
||||
variable: "--font-geist",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "agent-browser",
|
||||
description: "Headless browser automation CLI for AI agents",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body
|
||||
className={`${geist.variable} ${geistMono.variable} antialiased bg-zinc-950 text-zinc-100`}
|
||||
>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto pt-14 lg:pt-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>agent-browser</h1>
|
||||
<p>
|
||||
Headless browser automation CLI for AI agents. Fast Rust CLI with Node.js fallback.
|
||||
</p>
|
||||
|
||||
<CodeBlock code="npm install -g agent-browser" />
|
||||
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li><strong>Universal</strong> - Works with any AI agent: Claude Code, Cursor, Codex, Copilot, Gemini, opencode, and more</li>
|
||||
<li><strong>AI-first</strong> - Snapshot returns accessibility tree with refs for deterministic element selection</li>
|
||||
<li><strong>Fast</strong> - Native Rust CLI for instant command parsing</li>
|
||||
<li><strong>Complete</strong> - 50+ commands for navigation, forms, screenshots, network, storage</li>
|
||||
<li><strong>Sessions</strong> - Multiple isolated browser instances with separate auth</li>
|
||||
<li><strong>Cross-platform</strong> - macOS, Linux, Windows with native binaries</li>
|
||||
<li><strong>Serverless</strong> - Custom executable path for lightweight Chromium builds</li>
|
||||
</ul>
|
||||
|
||||
<h2>Example</h2>
|
||||
<CodeBlock code={`# Navigate and get snapshot
|
||||
agent-browser open example.com
|
||||
agent-browser snapshot -i
|
||||
|
||||
# Output:
|
||||
# - heading "Example Domain" [ref=e1]
|
||||
# - link "More information..." [ref=e2]
|
||||
|
||||
# Interact using refs
|
||||
agent-browser click @e2
|
||||
agent-browser screenshot page.png
|
||||
agent-browser close`} />
|
||||
|
||||
<h2>Why refs?</h2>
|
||||
<p>
|
||||
The <code>snapshot</code> command returns an accessibility tree where each element
|
||||
has a unique ref like <code>@e1</code>, <code>@e2</code>. This provides:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Deterministic</strong> - Ref points to exact element from snapshot</li>
|
||||
<li><strong>Fast</strong> - No DOM re-query needed</li>
|
||||
<li><strong>AI-friendly</strong> - LLMs can reliably parse and use refs</li>
|
||||
</ul>
|
||||
|
||||
<h2>Architecture</h2>
|
||||
<p>
|
||||
Client-daemon architecture for optimal performance:
|
||||
</p>
|
||||
<ol>
|
||||
<li><strong>Rust CLI</strong> - Parses commands, communicates with daemon</li>
|
||||
<li><strong>Node.js Daemon</strong> - Manages Playwright browser instance</li>
|
||||
</ol>
|
||||
<p>
|
||||
Daemon starts automatically and persists between commands.
|
||||
</p>
|
||||
|
||||
<h2>Platforms</h2>
|
||||
<p>
|
||||
Native Rust binaries for macOS (ARM64, x64), Linux (ARM64, x64), and Windows (x64).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function QuickStart() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>Quick Start</h1>
|
||||
|
||||
<h2>Basic workflow</h2>
|
||||
<CodeBlock code={`agent-browser open example.com
|
||||
agent-browser snapshot # Get accessibility tree with refs
|
||||
agent-browser click @e2 # Click by ref from snapshot
|
||||
agent-browser fill @e3 "test@example.com" # Fill by ref
|
||||
agent-browser get text @e1 # Get text by ref
|
||||
agent-browser screenshot # Base64 png to stdout
|
||||
agent-browser screenshot page.png # Save to file
|
||||
agent-browser close`} />
|
||||
|
||||
<h2>Traditional selectors</h2>
|
||||
<p>CSS selectors and semantic locators also supported:</p>
|
||||
<CodeBlock code={`agent-browser click "#submit"
|
||||
agent-browser fill "#email" "test@example.com"
|
||||
agent-browser find role button click --name "Submit"`} />
|
||||
|
||||
<h2>AI workflow</h2>
|
||||
<p>Optimal workflow for AI agents:</p>
|
||||
<CodeBlock code={`# 1. Navigate and get snapshot
|
||||
agent-browser open example.com
|
||||
agent-browser snapshot -i --json # AI parses tree and refs
|
||||
|
||||
# 2. AI identifies target refs from snapshot
|
||||
# 3. Execute actions using refs
|
||||
agent-browser click @e2
|
||||
agent-browser fill @e3 "input text"
|
||||
|
||||
# 4. Get new snapshot if page changed
|
||||
agent-browser snapshot -i --json`} />
|
||||
|
||||
<h2>Headed mode</h2>
|
||||
<p>Show browser window for debugging:</p>
|
||||
<CodeBlock code="agent-browser open example.com --headed" />
|
||||
|
||||
<h2>JSON output</h2>
|
||||
<p>Use <code>--json</code> for machine-readable output:</p>
|
||||
<CodeBlock code={`agent-browser snapshot --json
|
||||
agent-browser get text @e1 --json
|
||||
agent-browser is visible @e2 --json`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function Selectors() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>Selectors</h1>
|
||||
|
||||
<h2>Refs (recommended)</h2>
|
||||
<p>
|
||||
Refs provide deterministic element selection from snapshots. Best for AI agents.
|
||||
</p>
|
||||
<CodeBlock code={`# 1. Get snapshot with refs
|
||||
agent-browser snapshot
|
||||
# Output:
|
||||
# - heading "Example Domain" [ref=e1] [level=1]
|
||||
# - button "Submit" [ref=e2]
|
||||
# - textbox "Email" [ref=e3]
|
||||
# - link "Learn more" [ref=e4]
|
||||
|
||||
# 2. Use refs to interact
|
||||
agent-browser click @e2 # Click the button
|
||||
agent-browser fill @e3 "test@example.com" # Fill the textbox
|
||||
agent-browser get text @e1 # Get heading text
|
||||
agent-browser hover @e4 # Hover the link`} />
|
||||
|
||||
<h3>Why refs?</h3>
|
||||
<ul>
|
||||
<li><strong>Deterministic</strong> - Ref points to exact element from snapshot</li>
|
||||
<li><strong>Fast</strong> - No DOM re-query needed</li>
|
||||
<li><strong>AI-friendly</strong> - LLMs can reliably parse and use refs</li>
|
||||
</ul>
|
||||
|
||||
<h2>CSS selectors</h2>
|
||||
<CodeBlock code={`agent-browser click "#id"
|
||||
agent-browser click ".class"
|
||||
agent-browser click "div > button"
|
||||
agent-browser click "[data-testid='submit']"`} />
|
||||
|
||||
<h2>Text & XPath</h2>
|
||||
<CodeBlock code={`agent-browser click "text=Submit"
|
||||
agent-browser click "xpath=//button[@type='submit']"`} />
|
||||
|
||||
<h2>Semantic locators</h2>
|
||||
<p>Find elements by role, label, or other semantic properties:</p>
|
||||
<CodeBlock code={`agent-browser find role button click --name "Submit"
|
||||
agent-browser find label "Email" fill "test@test.com"
|
||||
agent-browser find placeholder "Search..." fill "query"
|
||||
agent-browser find testid "submit-btn" click`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function Sessions() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>Sessions</h1>
|
||||
<p>Run multiple isolated browser instances:</p>
|
||||
<CodeBlock code={`# Different sessions
|
||||
agent-browser --session agent1 open site-a.com
|
||||
agent-browser --session agent2 open site-b.com
|
||||
|
||||
# Or via environment variable
|
||||
AGENT_BROWSER_SESSION=agent1 agent-browser click "#btn"
|
||||
|
||||
# List active sessions
|
||||
agent-browser session list
|
||||
# Output:
|
||||
# Active sessions:
|
||||
# -> default
|
||||
# agent1
|
||||
|
||||
# Show current session
|
||||
agent-browser session`} />
|
||||
|
||||
<h2>Session isolation</h2>
|
||||
<p>Each session has its own:</p>
|
||||
<ul>
|
||||
<li>Browser instance</li>
|
||||
<li>Cookies and storage</li>
|
||||
<li>Navigation history</li>
|
||||
<li>Authentication state</li>
|
||||
</ul>
|
||||
|
||||
<h2>Authenticated sessions</h2>
|
||||
<p>
|
||||
Use <code>--headers</code> to set HTTP headers for a specific origin:
|
||||
</p>
|
||||
<CodeBlock code={`# Headers scoped to api.example.com only
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer <token>"}'
|
||||
|
||||
# Requests to api.example.com include the auth header
|
||||
agent-browser snapshot -i --json
|
||||
agent-browser click @e2
|
||||
|
||||
# Navigate to another domain - headers NOT sent
|
||||
agent-browser open other-site.com`} />
|
||||
<p>Useful for:</p>
|
||||
<ul>
|
||||
<li><strong>Skipping login flows</strong> - Authenticate via headers</li>
|
||||
<li><strong>Switching users</strong> - Different auth tokens per session</li>
|
||||
<li><strong>API testing</strong> - Access protected endpoints</li>
|
||||
<li><strong>Security</strong> - Headers scoped to origin, not leaked</li>
|
||||
</ul>
|
||||
|
||||
<h2>Multiple origins</h2>
|
||||
<CodeBlock code={`agent-browser open api.example.com --headers '{"Authorization": "Bearer token1"}'
|
||||
agent-browser open api.acme.com --headers '{"Authorization": "Bearer token2"}'`} />
|
||||
|
||||
<h2>Global headers</h2>
|
||||
<p>For headers on all domains:</p>
|
||||
<CodeBlock code={`agent-browser set headers '{"X-Custom-Header": "value"}'`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function Snapshots() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>Snapshots</h1>
|
||||
<p>
|
||||
The <code>snapshot</code> command returns the accessibility tree with refs for AI-friendly interaction.
|
||||
</p>
|
||||
|
||||
<h2>Options</h2>
|
||||
<p>Filter output to reduce size:</p>
|
||||
<CodeBlock code={`agent-browser snapshot # Full accessibility tree
|
||||
agent-browser snapshot -i # Interactive elements only
|
||||
agent-browser snapshot -c # Compact (remove empty elements)
|
||||
agent-browser snapshot -d 3 # Limit depth to 3 levels
|
||||
agent-browser snapshot -s "#main" # Scope to CSS selector
|
||||
agent-browser snapshot -i -c -d 5 # Combine options`} />
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>-i, --interactive</code></td>
|
||||
<td>Only interactive elements (buttons, links, inputs)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>-c, --compact</code></td>
|
||||
<td>Remove empty structural elements</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>-d, --depth</code></td>
|
||||
<td>Limit tree depth</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>-s, --selector</code></td>
|
||||
<td>Scope to CSS selector</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Output format</h2>
|
||||
<CodeBlock code={`agent-browser snapshot
|
||||
# Output:
|
||||
# - heading "Example Domain" [ref=e1] [level=1]
|
||||
# - button "Submit" [ref=e2]
|
||||
# - textbox "Email" [ref=e3]
|
||||
# - link "Learn more" [ref=e4]`} />
|
||||
|
||||
<h2>JSON output</h2>
|
||||
<p>Use <code>--json</code> for machine-readable output:</p>
|
||||
<CodeBlock code={`agent-browser snapshot --json
|
||||
# {"success":true,"data":{"snapshot":"...","refs":{"e1":{"role":"heading","name":"Title"},...}}}`} />
|
||||
|
||||
<h2>Best practices</h2>
|
||||
<ol>
|
||||
<li>Use <code>-i</code> to reduce output to actionable elements</li>
|
||||
<li>Use <code>--json</code> for structured parsing</li>
|
||||
<li>Re-snapshot after page changes to get updated refs</li>
|
||||
<li>Scope with <code>-s</code> for specific page sections</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { CodeBlock } from "@/components/code-block";
|
||||
|
||||
export default function Streaming() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="prose">
|
||||
<h1>Streaming</h1>
|
||||
<p>
|
||||
Stream the browser viewport via WebSocket for live preview or "pair browsing"
|
||||
where a human can watch and interact alongside an AI agent.
|
||||
</p>
|
||||
|
||||
<h2>Enable streaming</h2>
|
||||
<p>
|
||||
Set the <code>AGENT_BROWSER_STREAM_PORT</code> environment variable to start
|
||||
a WebSocket server:
|
||||
</p>
|
||||
<CodeBlock code={`AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com`} />
|
||||
|
||||
<p>
|
||||
The server streams viewport frames and accepts input events (mouse, keyboard, touch).
|
||||
</p>
|
||||
|
||||
<h2>WebSocket protocol</h2>
|
||||
<p>Connect to <code>ws://localhost:9223</code> to receive frames and send input.</p>
|
||||
|
||||
<h3>Frame messages</h3>
|
||||
<p>The server sends frame messages with base64-encoded images:</p>
|
||||
<CodeBlock code={`{
|
||||
"type": "frame",
|
||||
"data": "<base64-encoded-jpeg>",
|
||||
"metadata": {
|
||||
"deviceWidth": 1280,
|
||||
"deviceHeight": 720,
|
||||
"pageScaleFactor": 1,
|
||||
"offsetTop": 0,
|
||||
"scrollOffsetX": 0,
|
||||
"scrollOffsetY": 0
|
||||
}
|
||||
}`} />
|
||||
|
||||
<h3>Status messages</h3>
|
||||
<p>Connection and screencast status:</p>
|
||||
<CodeBlock code={`{
|
||||
"type": "status",
|
||||
"connected": true,
|
||||
"screencasting": true,
|
||||
"viewportWidth": 1280,
|
||||
"viewportHeight": 720
|
||||
}`} />
|
||||
|
||||
<h2>Input injection</h2>
|
||||
<p>Send input events to control the browser remotely.</p>
|
||||
|
||||
<h3>Mouse events</h3>
|
||||
<CodeBlock code={`// Click
|
||||
{
|
||||
"type": "input_mouse",
|
||||
"eventType": "mousePressed",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"button": "left",
|
||||
"clickCount": 1
|
||||
}
|
||||
|
||||
// Release
|
||||
{
|
||||
"type": "input_mouse",
|
||||
"eventType": "mouseReleased",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"button": "left"
|
||||
}
|
||||
|
||||
// Move
|
||||
{
|
||||
"type": "input_mouse",
|
||||
"eventType": "mouseMoved",
|
||||
"x": 150,
|
||||
"y": 250
|
||||
}
|
||||
|
||||
// Scroll
|
||||
{
|
||||
"type": "input_mouse",
|
||||
"eventType": "mouseWheel",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"deltaX": 0,
|
||||
"deltaY": 100
|
||||
}`} />
|
||||
|
||||
<h3>Keyboard events</h3>
|
||||
<CodeBlock code={`// Key down
|
||||
{
|
||||
"type": "input_keyboard",
|
||||
"eventType": "keyDown",
|
||||
"key": "Enter",
|
||||
"code": "Enter"
|
||||
}
|
||||
|
||||
// Key up
|
||||
{
|
||||
"type": "input_keyboard",
|
||||
"eventType": "keyUp",
|
||||
"key": "Enter",
|
||||
"code": "Enter"
|
||||
}
|
||||
|
||||
// Type character
|
||||
{
|
||||
"type": "input_keyboard",
|
||||
"eventType": "char",
|
||||
"text": "a"
|
||||
}
|
||||
|
||||
// With modifiers (1=Alt, 2=Ctrl, 4=Meta, 8=Shift)
|
||||
{
|
||||
"type": "input_keyboard",
|
||||
"eventType": "keyDown",
|
||||
"key": "c",
|
||||
"code": "KeyC",
|
||||
"modifiers": 2
|
||||
}`} />
|
||||
|
||||
<h3>Touch events</h3>
|
||||
<CodeBlock code={`// Touch start
|
||||
{
|
||||
"type": "input_touch",
|
||||
"eventType": "touchStart",
|
||||
"touchPoints": [{ "x": 100, "y": 200 }]
|
||||
}
|
||||
|
||||
// Touch move
|
||||
{
|
||||
"type": "input_touch",
|
||||
"eventType": "touchMove",
|
||||
"touchPoints": [{ "x": 150, "y": 250 }]
|
||||
}
|
||||
|
||||
// Touch end
|
||||
{
|
||||
"type": "input_touch",
|
||||
"eventType": "touchEnd",
|
||||
"touchPoints": []
|
||||
}
|
||||
|
||||
// Multi-touch (pinch zoom)
|
||||
{
|
||||
"type": "input_touch",
|
||||
"eventType": "touchStart",
|
||||
"touchPoints": [
|
||||
{ "x": 100, "y": 200, "id": 0 },
|
||||
{ "x": 200, "y": 200, "id": 1 }
|
||||
]
|
||||
}`} />
|
||||
|
||||
<h2>Programmatic API</h2>
|
||||
<p>For advanced use, control streaming directly via the TypeScript API:</p>
|
||||
<CodeBlock code={`import { BrowserManager } from 'agent-browser';
|
||||
|
||||
const browser = new BrowserManager();
|
||||
await browser.launch({ headless: true });
|
||||
await browser.navigate('https://example.com');
|
||||
|
||||
// Start screencast with callback
|
||||
await browser.startScreencast((frame) => {
|
||||
console.log('Frame:', frame.metadata.deviceWidth, 'x', frame.metadata.deviceHeight);
|
||||
// frame.data is base64-encoded image
|
||||
}, {
|
||||
format: 'jpeg', // or 'png'
|
||||
quality: 80, // 0-100, jpeg only
|
||||
maxWidth: 1280,
|
||||
maxHeight: 720,
|
||||
everyNthFrame: 1
|
||||
});
|
||||
|
||||
// Inject mouse event
|
||||
await browser.injectMouseEvent({
|
||||
type: 'mousePressed',
|
||||
x: 100,
|
||||
y: 200,
|
||||
button: 'left',
|
||||
clickCount: 1
|
||||
});
|
||||
|
||||
// Inject keyboard event
|
||||
await browser.injectKeyboardEvent({
|
||||
type: 'keyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter'
|
||||
});
|
||||
|
||||
// Inject touch event
|
||||
await browser.injectTouchEvent({
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x: 100, y: 200 }]
|
||||
});
|
||||
|
||||
// Check if screencasting
|
||||
console.log('Active:', browser.isScreencasting());
|
||||
|
||||
// Stop screencast
|
||||
await browser.stopScreencast();`} />
|
||||
|
||||
<h2>Use cases</h2>
|
||||
<ul>
|
||||
<li><strong>Pair browsing</strong> - Human watches and assists AI agent in real-time</li>
|
||||
<li><strong>Remote preview</strong> - View browser output in a separate UI</li>
|
||||
<li><strong>Recording</strong> - Capture frames for video generation</li>
|
||||
<li><strong>Mobile testing</strong> - Inject touch events for mobile emulation</li>
|
||||
<li><strong>Accessibility testing</strong> - Manual interaction during automated tests</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { codeToHtml } from "shiki";
|
||||
import { CopyButton } from "./copy-button";
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export async function CodeBlock({ code, lang = "bash" }: CodeBlockProps) {
|
||||
const trimmedCode = code.trim();
|
||||
const html = await codeToHtml(trimmedCode, {
|
||||
lang,
|
||||
theme: "github-dark-default",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="code-block relative group">
|
||||
<CopyButton code={trimmedCode} />
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface CopyButtonProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ code }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
// Optionally, you could set an error state or show a toast notification here
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1.5 rounded text-[#666] hover:text-[#999] hover:bg-[#333] opacity-0 group-hover:opacity-100 transition-all"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Introduction", href: "/" },
|
||||
{ name: "Installation", href: "/installation" },
|
||||
{ name: "Quick Start", href: "/quick-start" },
|
||||
{ name: "Commands", href: "/commands" },
|
||||
{ name: "Selectors", href: "/selectors" },
|
||||
{ name: "Sessions", href: "/sessions" },
|
||||
{ name: "Snapshots", href: "/snapshots" },
|
||||
{ name: "Streaming", href: "/streaming" },
|
||||
{ name: "Agent Mode", href: "/agent-mode" },
|
||||
{ name: "CDP Mode", href: "/cdp-mode" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header */}
|
||||
<header className="lg:hidden fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm border-b border-[#222] px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="text-sm font-medium">
|
||||
agent-browser
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-2 -mr-2 text-[#888] hover:text-white transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isOpen ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-black/80"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
fixed lg:sticky top-0 left-0 z-50 lg:z-auto
|
||||
w-56 lg:w-48 h-screen
|
||||
bg-black border-r border-[#222]
|
||||
transform transition-transform duration-150 ease-out
|
||||
${isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
|
||||
pt-14 lg:pt-0
|
||||
`}
|
||||
>
|
||||
<div className="h-full overflow-y-auto p-5">
|
||||
{/* Desktop header */}
|
||||
<div className="mb-8 hidden lg:block">
|
||||
<Link href="/" className="text-sm font-medium">
|
||||
agent-browser
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-0.5">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`block px-2 py-1.5 text-[13px] transition-colors ${
|
||||
isActive
|
||||
? "text-white"
|
||||
: "text-[#666] hover:text-[#999]"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-[#222] space-y-0.5">
|
||||
<a
|
||||
href="https://github.com/vercel-labs/agent-browser"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-2 py-1.5 text-[13px] text-[#666] hover:text-[#999] transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/agent-browser"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-2 py-1.5 text-[13px] text-[#666] hover:text-[#999] transition-colors"
|
||||
>
|
||||
npm
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
(() => {
|
||||
const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST';
|
||||
const RESPONSE_TYPE = 'AB_TAB_GROUP_RESPONSE';
|
||||
|
||||
const CONTENT_EVENT_TYPE = 'AB_CONTENT_EVENT';
|
||||
const CONTENT_EXECUTE_ACTION = 'AB_CONTENT_EXECUTE_ACTION';
|
||||
const CONTENT_GET_DOM_STATE = 'AB_CONTENT_GET_DOM_STATE';
|
||||
const CONTENT_PING = 'AB_CONTENT_PING';
|
||||
const PAGE_BRIDGE_EVENT = 'AB_PAGE_BRIDGE_EVENT';
|
||||
const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1';
|
||||
|
||||
const mutationState = {
|
||||
total: 0,
|
||||
recent: [],
|
||||
observerReady: false,
|
||||
};
|
||||
|
||||
function pushMutationSummary(entry) {
|
||||
mutationState.total += 1;
|
||||
mutationState.recent.push({
|
||||
...entry,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (mutationState.recent.length > 40) {
|
||||
mutationState.recent.splice(0, mutationState.recent.length - 40);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeValue(value, depth = 0) {
|
||||
if (value === null || typeof value === 'undefined') return value;
|
||||
if (typeof value === 'string') return value.slice(0, 300);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
||||
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
||||
if (depth > 2) return '[depth-limit]';
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 10).map((item) => serializeValue(item, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [key, entry] of Object.entries(value).slice(0, 15)) {
|
||||
out[key] = serializeValue(entry, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return String(value).slice(0, 300);
|
||||
}
|
||||
|
||||
function sendRuntimeEvent(kind, payload) {
|
||||
try {
|
||||
chrome.runtime.sendMessage({
|
||||
type: CONTENT_EVENT_TYPE,
|
||||
kind,
|
||||
payload: serializeValue(payload),
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Ignore runtime channel errors.
|
||||
}
|
||||
}
|
||||
|
||||
function getPageBridgeEnabled() {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
chrome.storage.local.get([STORAGE_OPTIONS_KEY], (result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const rawOptions = result?.[STORAGE_OPTIONS_KEY];
|
||||
resolve(Boolean(rawOptions && typeof rawOptions === 'object' && rawOptions.pageBridgeEnabled === true));
|
||||
});
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function installPageBridge() {
|
||||
// Receives events emitted by the injected page-world hook script.
|
||||
const bridgeListener = (event) => {
|
||||
if (event.source !== window) return;
|
||||
const data = event.data;
|
||||
if (!data || data.type !== PAGE_BRIDGE_EVENT) return;
|
||||
sendRuntimeEvent(data.kind || 'page-event', data.payload || {});
|
||||
};
|
||||
|
||||
window.addEventListener('message', bridgeListener);
|
||||
|
||||
const parent = document.documentElement || document.head || document.body;
|
||||
if (!parent) return;
|
||||
|
||||
if (!(await getPageBridgeEnabled())) {
|
||||
sendRuntimeEvent('lifecycle', {
|
||||
event: 'bridge-disabled-default',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use external extension script instead of inline text to reduce CSP conflicts.
|
||||
const script = document.createElement('script');
|
||||
script.src = chrome.runtime.getURL('page-bridge.js');
|
||||
script.async = false;
|
||||
script.dataset.abBridgeEvent = PAGE_BRIDGE_EVENT;
|
||||
script.onload = () => script.remove();
|
||||
script.onerror = () => {
|
||||
sendRuntimeEvent('lifecycle', {
|
||||
event: 'bridge-load-failed',
|
||||
host: window.location.hostname,
|
||||
});
|
||||
script.remove();
|
||||
};
|
||||
parent.appendChild(script);
|
||||
}
|
||||
|
||||
function ensureMutationObserver() {
|
||||
if (mutationState.observerReady) return;
|
||||
if (!document.documentElement) return;
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
const summary = {
|
||||
records: records.length,
|
||||
addedNodes: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
|
||||
for (const record of records.slice(0, 40)) {
|
||||
summary.addedNodes += record.addedNodes?.length || 0;
|
||||
summary.removedNodes += record.removedNodes?.length || 0;
|
||||
}
|
||||
|
||||
pushMutationSummary(summary);
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'disabled', 'aria-hidden'],
|
||||
});
|
||||
|
||||
mutationState.observerReady = true;
|
||||
}
|
||||
|
||||
function toSimpleNode(element) {
|
||||
if (!element || typeof element !== 'object') return null;
|
||||
const node = {
|
||||
tag: element.tagName?.toLowerCase() || 'unknown',
|
||||
id: element.id || undefined,
|
||||
className: typeof element.className === 'string' ? element.className.slice(0, 120) : '',
|
||||
role: element.getAttribute?.('role') || undefined,
|
||||
name:
|
||||
element.getAttribute?.('aria-label') ||
|
||||
element.getAttribute?.('name') ||
|
||||
element.getAttribute?.('placeholder') ||
|
||||
'',
|
||||
text: (element.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 160),
|
||||
disabled: element.disabled === true,
|
||||
hidden: element.hidden === true,
|
||||
};
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function collectInteractiveElements(root, limit = 80) {
|
||||
const selector = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'summary',
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[tabindex]'
|
||||
].join(',');
|
||||
|
||||
const out = [];
|
||||
const nodes = root.querySelectorAll(selector);
|
||||
for (const element of nodes) {
|
||||
if (out.length >= limit) break;
|
||||
out.push(toSimpleNode(element));
|
||||
}
|
||||
return out.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectDomState(options = {}) {
|
||||
const selector = typeof options.selector === 'string' ? options.selector.trim() : '';
|
||||
const root = selector ? document.querySelector(selector) : document.body || document.documentElement;
|
||||
|
||||
if (!root) {
|
||||
return {
|
||||
ok: false,
|
||||
error: selector ? `selector-not-found: ${selector}` : 'root-not-found',
|
||||
};
|
||||
}
|
||||
|
||||
const textPreview = (root.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 1000);
|
||||
const interactiveOnly = options.interactiveOnly === true;
|
||||
const interactiveElements = collectInteractiveElements(root, options.maxNodes || 80);
|
||||
|
||||
const dom = {
|
||||
href: window.location.href,
|
||||
title: document.title,
|
||||
readyState: document.readyState,
|
||||
selector: selector || null,
|
||||
rootTag: root.tagName?.toLowerCase() || 'unknown',
|
||||
textPreview,
|
||||
interactiveCount: interactiveElements.length,
|
||||
interactiveElements,
|
||||
mutation: {
|
||||
total: mutationState.total,
|
||||
recent: mutationState.recent.slice(-10),
|
||||
},
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (interactiveOnly) {
|
||||
dom.textPreview = '';
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: dom,
|
||||
};
|
||||
}
|
||||
|
||||
function queryElement(selector) {
|
||||
if (typeof selector !== 'string' || selector.trim().length === 0) {
|
||||
throw new Error('selector is required');
|
||||
}
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error(`Element not found: ${selector}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function focusElement(element) {
|
||||
if (typeof element.focus === 'function') {
|
||||
element.focus({ preventScroll: false });
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchInputEvents(element) {
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function executeAction(command, args = {}) {
|
||||
switch (command) {
|
||||
case 'click': {
|
||||
const element = queryElement(args.selector);
|
||||
focusElement(element);
|
||||
element.click();
|
||||
return { ok: true, action: command, selector: args.selector };
|
||||
}
|
||||
case 'fill': {
|
||||
const element = queryElement(args.selector);
|
||||
if (!('value' in element)) {
|
||||
throw new Error(`Element is not fillable: ${args.selector}`);
|
||||
}
|
||||
focusElement(element);
|
||||
element.value = typeof args.value === 'string' ? args.value : String(args.value || '');
|
||||
dispatchInputEvents(element);
|
||||
return { ok: true, action: command, selector: args.selector, valueLength: element.value.length };
|
||||
}
|
||||
case 'press': {
|
||||
const key = typeof args.key === 'string' && args.key.trim().length > 0 ? args.key.trim() : 'Enter';
|
||||
let target;
|
||||
if (typeof args.selector === 'string' && args.selector.trim().length > 0) {
|
||||
target = queryElement(args.selector);
|
||||
focusElement(target);
|
||||
} else {
|
||||
target = document.activeElement || document.body;
|
||||
}
|
||||
|
||||
const down = new KeyboardEvent('keydown', { key, bubbles: true });
|
||||
const up = new KeyboardEvent('keyup', { key, bubbles: true });
|
||||
target.dispatchEvent(down);
|
||||
target.dispatchEvent(up);
|
||||
return { ok: true, action: command, key };
|
||||
}
|
||||
case 'eval': {
|
||||
if (typeof args.expression !== 'string' || args.expression.trim().length === 0) {
|
||||
throw new Error('expression is required');
|
||||
}
|
||||
const fn = new Function(`return (${args.expression});`);
|
||||
const result = fn();
|
||||
return { ok: true, action: command, result: serializeValue(result) };
|
||||
}
|
||||
case 'snapshot': {
|
||||
return {
|
||||
ok: true,
|
||||
action: command,
|
||||
...collectDomState({
|
||||
selector: args.selector,
|
||||
interactiveOnly: args.interactiveOnly === true,
|
||||
maxNodes: args.maxNodes,
|
||||
}),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown content action: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
ensureMutationObserver();
|
||||
installPageBridge();
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
if (!data || data.type !== REQUEST_TYPE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
type: REQUEST_TYPE,
|
||||
nonce: data.nonce,
|
||||
session: data.session,
|
||||
groupTitle: data.groupTitle,
|
||||
pluginId: data.pluginId,
|
||||
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
chrome.runtime.sendMessage(request, (response) => {
|
||||
const lastError = chrome.runtime.lastError;
|
||||
if (lastError) {
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: false,
|
||||
error: lastError.message,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = response && typeof response === 'object' ? response : { ok: false };
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: payload.ok === true,
|
||||
extensionId:
|
||||
typeof payload.extensionId === 'string' && payload.extensionId.length > 0
|
||||
? payload.extensionId
|
||||
: chrome.runtime.id,
|
||||
groupId: typeof payload.groupId === 'number' ? payload.groupId : undefined,
|
||||
windowId: typeof payload.windowId === 'number' ? payload.windowId : undefined,
|
||||
color: typeof payload.color === 'string' ? payload.color : undefined,
|
||||
collapsed: payload.collapsed === true,
|
||||
policy:
|
||||
payload.policy && typeof payload.policy === 'object'
|
||||
? {
|
||||
enforced: payload.policy.enforced === true,
|
||||
blocked: payload.policy.blocked === true,
|
||||
reason:
|
||||
typeof payload.policy.reason === 'string' ? payload.policy.reason : undefined,
|
||||
}
|
||||
: undefined,
|
||||
riskHints: Array.isArray(payload.riskHints) ? payload.riskHints : undefined,
|
||||
error: typeof payload.error === 'string' ? payload.error : undefined,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: false,
|
||||
error: errorMessage,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (!message || typeof message !== 'object') return;
|
||||
|
||||
if (message.type === CONTENT_PING) {
|
||||
sendResponse({
|
||||
ok: true,
|
||||
href: window.location.href,
|
||||
title: document.title,
|
||||
readyState: document.readyState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === CONTENT_GET_DOM_STATE) {
|
||||
sendResponse(collectDomState(message.options || {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === CONTENT_EXECUTE_ACTION) {
|
||||
executeAction(message.command, message.args || {})
|
||||
.then((result) => sendResponse(result))
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
action: message.command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,7 +0,0 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="128" height="128" rx="32" fill="#1A73E8"/>
|
||||
<rect x="30" y="34" width="68" height="10" rx="2" fill="white"/>
|
||||
<rect x="30" y="54" width="48" height="10" rx="2" fill="white" fill-opacity="0.8"/>
|
||||
<rect x="30" y="74" width="28" height="10" rx="2" fill="white" fill-opacity="0.6"/>
|
||||
<circle cx="94" cy="90" r="10" fill="#34A853" stroke="#1A73E8" stroke-width="4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 488 B |
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.2.0",
|
||||
"description": "Session-aware tab grouping and coordination for CDP-driven agent-browser workflows.",
|
||||
"icons": {
|
||||
"128": "icons/icon.svg"
|
||||
},
|
||||
"permissions": ["tabs", "tabGroups", "downloads", "storage", "sidePanel", "alarms"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "service-worker.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "agent-browser-stealth"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"run_at": "document_start",
|
||||
"match_about_blank": true
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["page-bridge.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
(() => {
|
||||
if (window.__AB_STEALTH_BRIDGE_INSTALLED__) return;
|
||||
window.__AB_STEALTH_BRIDGE_INSTALLED__ = true;
|
||||
|
||||
const currentScript = document.currentScript;
|
||||
const TYPE = currentScript?.dataset?.abBridgeEvent || 'AB_PAGE_BRIDGE_EVENT';
|
||||
|
||||
const post = (kind, payload) => {
|
||||
try {
|
||||
window.postMessage({ type: TYPE, kind, payload, timestamp: Date.now() }, '*');
|
||||
} catch {
|
||||
// Ignore post failures.
|
||||
}
|
||||
};
|
||||
|
||||
const serializeArg = (value, depth = 0) => {
|
||||
if (value === null || typeof value === 'undefined') return value;
|
||||
if (typeof value === 'string') return value.slice(0, 250);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
||||
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
||||
if (depth > 2) return '[depth-limit]';
|
||||
if (Array.isArray(value)) return value.slice(0, 10).map((item) => serializeArg(item, depth + 1));
|
||||
if (typeof value === 'object') {
|
||||
const out = {};
|
||||
const entries = Object.entries(value).slice(0, 12);
|
||||
for (const [k, v] of entries) {
|
||||
out[k] = serializeArg(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return String(value).slice(0, 250);
|
||||
};
|
||||
|
||||
const patchConsoleMethod = (name) => {
|
||||
const original = console[name];
|
||||
if (typeof original !== 'function') return;
|
||||
console[name] = function patchedConsole(...args) {
|
||||
post('console', {
|
||||
level: name,
|
||||
args: args.map((arg) => serializeArg(arg)),
|
||||
});
|
||||
return original.apply(this, args);
|
||||
};
|
||||
};
|
||||
|
||||
patchConsoleMethod('error');
|
||||
patchConsoleMethod('warn');
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
post('console', {
|
||||
level: 'error',
|
||||
message: event.message,
|
||||
source: event.filename,
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
post('console', {
|
||||
level: 'error',
|
||||
message: 'Unhandled rejection',
|
||||
reason: serializeArg(event.reason),
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof window.fetch === 'function') {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (...args) => {
|
||||
const startedAt = Date.now();
|
||||
const requestInfo = args[0];
|
||||
const requestInit = args[1] || {};
|
||||
const method = requestInit.method || 'GET';
|
||||
const url = typeof requestInfo === 'string' ? requestInfo : requestInfo?.url || '';
|
||||
|
||||
try {
|
||||
const response = await originalFetch(...args);
|
||||
post('network', {
|
||||
transport: 'fetch',
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
post('network', {
|
||||
transport: 'fetch',
|
||||
method,
|
||||
url,
|
||||
error: serializeArg(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window.XMLHttpRequest === 'function') {
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
const originalSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) {
|
||||
this.__abRequestMeta = {
|
||||
method: typeof method === 'string' ? method : 'GET',
|
||||
url: typeof url === 'string' ? url : String(url || ''),
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
return originalOpen.call(this, method, url, ...rest);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function patchedSend(...args) {
|
||||
this.addEventListener('loadend', () => {
|
||||
const meta = this.__abRequestMeta || {};
|
||||
post('network', {
|
||||
transport: 'xhr',
|
||||
method: meta.method || 'GET',
|
||||
url: meta.url || '',
|
||||
status: this.status,
|
||||
ok: this.status >= 200 && this.status < 400,
|
||||
durationMs: Date.now() - (meta.startedAt || Date.now()),
|
||||
});
|
||||
});
|
||||
return originalSend.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
post('lifecycle', {
|
||||
event: 'bridge-installed',
|
||||
href: location.href,
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,258 +0,0 @@
|
||||
:root {
|
||||
--bg: #f3f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f6f8fb;
|
||||
--primary: #1769e0;
|
||||
--primary-hover: #0f58c0;
|
||||
--border: #d8dde4;
|
||||
--text-main: #18212f;
|
||||
--text-secondary: #4a5568;
|
||||
--text-muted: #667287;
|
||||
--success: #117a3d;
|
||||
--warning: #b86d00;
|
||||
--danger: #bd1e24;
|
||||
--radius: 10px;
|
||||
--mono: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text-main);
|
||||
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
all: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--surface);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--primary);
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-line {
|
||||
min-height: 18px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-line.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-line.warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-line.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-main);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input.mono,
|
||||
textarea.mono,
|
||||
code,
|
||||
pre {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
background: #0f172a;
|
||||
color: #dce6fb;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.item-url {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.caption {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 7px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
background: #f0f4fa;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 14px 0;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>agent-browser-stealth panel</title>
|
||||
<link rel="stylesheet" href="sidepanel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>agent-browser-stealth</h1>
|
||||
<div class="actions">
|
||||
<button id="refresh-btn" type="button">Refresh</button>
|
||||
<button id="cleanup-btn" type="button">Clean Empty Groups</button>
|
||||
</div>
|
||||
<div id="status-line" class="status-line"></div>
|
||||
</header>
|
||||
|
||||
<section id="control" class="card"></section>
|
||||
<section id="summary" class="card"></section>
|
||||
<section id="automation" class="card"></section>
|
||||
<section id="developer" class="card"></section>
|
||||
<section id="sessions" class="stack"></section>
|
||||
<section id="downloads" class="card"></section>
|
||||
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
+38
-21
@@ -1,56 +1,73 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.24.0-fork.1",
|
||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||
"name": "agent-browser",
|
||||
"version": "0.5.0",
|
||||
"description": "Headless browser automation CLI for AI agents",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"scripts",
|
||||
"skills",
|
||||
"extensions"
|
||||
"skills"
|
||||
],
|
||||
"bin": {
|
||||
"agent-browser-stealth": "./bin/agent-browser.js",
|
||||
"agent-browser": "./bin/agent-browser.js",
|
||||
"abs": "./bin/agent-browser.js"
|
||||
"agent-browser": "./bin/agent-browser"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"version:sync": "node scripts/sync-version.js",
|
||||
"version": "npm run version:sync && git add cli/Cargo.toml",
|
||||
"build": "tsc",
|
||||
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
|
||||
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
|
||||
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
||||
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
||||
"release": "npm run version:sync && npm run build:all-platforms && npm publish --tag fork",
|
||||
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm publish",
|
||||
"start": "node dist/daemon.js",
|
||||
"dev": "tsx src/daemon.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
"publishConfig": {
|
||||
"tag": "fork"
|
||||
},
|
||||
"keywords": [
|
||||
"browser",
|
||||
"automation",
|
||||
"headless",
|
||||
"chrome",
|
||||
"cdp",
|
||||
"playwright",
|
||||
"cli",
|
||||
"agent",
|
||||
"stealth",
|
||||
"anti-detection"
|
||||
"agent"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/leeguooooo/agent-browser-stealth.git"
|
||||
"url": "git+https://github.com/vercel-labs/agent-browser.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/leeguooooo/agent-browser-stealth/issues"
|
||||
"url": "https://github.com/vercel-labs/agent-browser/issues"
|
||||
},
|
||||
"homepage": "https://github.com/vercel-labs/agent-browser#readme",
|
||||
"dependencies": {
|
||||
"playwright-core": "^1.57.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"homepage": "https://github.com/leeguooooo/agent-browser-stealth",
|
||||
"devDependencies": {
|
||||
"husky": "^9.0.11"
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.11",
|
||||
"playwright": "^1.57.0",
|
||||
"prettier": "^3.7.4",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.ts": "prettier --write"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+786
-10424
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
- '.'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user